From e5fc3271e06fd808174195286898a18cf80f8011 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:16:22 -0500 Subject: [PATCH 01/15] remove duplicate item_count code --- src/Rules.py | 57 +++++++++++++++++++++------------------------------- 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index ddbf0078..5f0464c6 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Any from enum import IntEnum from operator import eq, ge, le @@ -178,43 +178,32 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = total = 0 if require_type == 'category': - category_items = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] - category_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]] - category_items_counts = sum([items_counts.get(category_item["name"], 0) for category_item in category_items]) - if item_count.lower() == 'all': - item_count = category_items_counts - elif item_count.lower() == 'half': - item_count = int(category_items_counts / 2) - elif item_count.endswith('%') and len(item_count) > 1: - percent = clamp(float(item_count[:-1]) / 100, 0, 1) - item_count = math.ceil(category_items_counts * percent) - else: - try: - item_count = int(item_count) - except ValueError as e: - raise ValueError(f"Invalid item count `{item_name}` in {area}.") from e - - for category_item in category_items: - total += state.count(category_item["name"], player) - - if total >= item_count: - requires_list = requires_list.replace(item_base, "1") - elif require_type == 'item': - item_current_count = items_counts.get(item_name, 0) - if item_count.lower() == 'all': - item_count = item_current_count - elif item_count.lower() == 'half': - item_count = int(item_current_count / 2) - elif item_count.endswith('%') and len(item_count) > 1: - percent = clamp(float(item_count[:-1]) / 100, 0, 1) - item_count = math.ceil(item_current_count * percent) - else: - item_count = int(item_count) + valid_items: list[dict[str, Any]] = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] + valid_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]] + else: + valid_items = [world.item_name_to_item[item_name]] + + item_current_count = sum([items_counts.get(valid_item["name"], 0) for valid_item in valid_items]) - total = state.count(item_name, player) + if item_count.lower() == 'all': + item_count = item_current_count + elif item_count.lower() == 'half': + item_count = int(item_current_count / 2) + elif item_count.endswith('%') and len(item_count) > 1: + percent = clamp(float(item_count[:-1]) / 100, 0, 1) + item_count = math.ceil(item_current_count * percent) + + try: + item_count = int(item_count) + except ValueError as e: + raise ValueError(f"Invalid item count `{item_name}` in {area}.") from e + + for valid_item in valid_items: + total += state.count(valid_item["name"], player) if total >= item_count: requires_list = requires_list.replace(item_base, "1") + break if total <= item_count: requires_list = requires_list.replace(item_base, "0") From f2388ba6b8dd6e7c4d3f6f6ca036fe53885c19fb Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:26:00 -0500 Subject: [PATCH 02/15] Misc small changes --- src/Rules.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index 5f0464c6..9d7b401e 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -158,17 +158,20 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = # parse user written statement into list of each item for item in re.findall(r'\|[^|]+\|', requires_list): - require_type = 'item' + if item not in requires_list: + # previous instance of this item was already processed + continue + require_category = False if '|@' in item: - require_type = 'category' + require_category = True item_base = item item = item.lstrip('|@$').rstrip('|') item_parts = item.split(":") # type: list[str] item_name = item - item_count = "1" + item_count: str | int = "1" if len(item_parts) > 1: @@ -177,7 +180,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = total = 0 - if require_type == 'category': + if require_category: valid_items: list[dict[str, Any]] = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] valid_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]] else: @@ -205,7 +208,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = requires_list = requires_list.replace(item_base, "1") break - if total <= item_count: + if total < item_count: requires_list = requires_list.replace(item_base, "0") requires_list = re.sub(r'\s?\bAND\b\s?', '&', requires_list, count=0, flags=re.IGNORECASE) From 65805029abfe1368b5f17c7a3338b42ff273d50a Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:49:17 -0500 Subject: [PATCH 03/15] added some misc type annotation to make mypy happy --- src/Rules.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index 9d7b401e..fc9081d5 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -45,10 +45,10 @@ def construct_logic_error(location_or_region: dict, source: LogicErrorSource) -> return KeyError(f"Invalid 'requires' for {object_type} '{object_name}': {source_text} (ERROR {source})") -def infix_to_postfix(expr, location): - prec = {"&": 2, "|": 2, "!": 3} - stack = [] - postfix = "" +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: @@ -73,8 +73,8 @@ def infix_to_postfix(expr, location): return postfix -def evaluate_postfix(expr: str, location: str) -> bool: - stack = [] +def evaluate_postfix(expr: str, location: dict) -> bool: + stack: list[bool] = [] try: for c in expr: @@ -167,9 +167,9 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = require_category = True item_base = item - item = item.lstrip('|@$').rstrip('|') + item: str = item.lstrip('|@$').rstrip('|') - item_parts = item.split(":") # type: list[str] + item_parts: list[str] = item.rsplit(":", 1) item_name = item item_count: str | int = "1" @@ -338,7 +338,7 @@ def allRegionsAccessible(state): # Victory requirement multiworld.completion_condition[player] = lambda state: state.has("__Victory__", player) - def convert_req_function_args(state: CollectionState, func, args: list[str], areaName: str): + def convert_req_function_args(state: CollectionState, func, args: list[str| Any], areaName: str): parameters = inspect.signature(func).parameters knownParameters = [World, 'ManualWorld', MultiWorld, CollectionState] index = -1 From b4904c55eba760467a55be88226c3fd25c2b224a Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:10:24 -0500 Subject: [PATCH 04/15] fix getting event items --- src/Rules.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index fc9081d5..0c1c74c3 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -179,14 +179,14 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = item_count = item_parts[1].strip() total = 0 - + valid_items: list[str] = [] if require_category: - valid_items: list[dict[str, Any]] = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] - valid_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]] + valid_items.extend([item["name"] for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]]) + valid_items.extend([event["name"] for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]]) else: - valid_items = [world.item_name_to_item[item_name]] + valid_items.append(item_name) - item_current_count = sum([items_counts.get(valid_item["name"], 0) for valid_item in valid_items]) + item_current_count = sum([items_counts.get(valid_item, 0) for valid_item in valid_items]) if item_count.lower() == 'all': item_count = item_current_count @@ -202,7 +202,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = raise ValueError(f"Invalid item count `{item_name}` in {area}.") from e for valid_item in valid_items: - total += state.count(valid_item["name"], player) + total += state.count(valid_item, player) if total >= item_count: requires_list = requires_list.replace(item_base, "1") From 252f15593bcab90477132789bc3c7574fbed2560 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 2 Feb 2026 18:20:01 -0500 Subject: [PATCH 05/15] let devs use : in item name that can be required --- src/DataValidation.py | 73 +++++++++++++++++++------------------------ src/Rules.py | 9 ++++-- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/DataValidation.py b/src/DataValidation.py index d9c09706..87759772 100644 --- a/src/DataValidation.py +++ b/src/DataValidation.py @@ -31,35 +31,31 @@ def checkItemNamesInLocationRequires(): if item.lower() == "or" or item.lower() == "and" or item == ")" or item == "(": continue else: - # if it's a category, validate that the category exists - if '@' in item: - item = item.replace("|", "") - item_parts = item.split(":") - item_name = item + is_category = '|@' in item + item: str = item.lstrip('|@').rstrip('|') + item_parts = item.rsplit(":", 1) + item_name = item - if len(item_parts) > 1: - item_name = item_parts[0] + if len(item_parts) > 1: + item_name = item_parts[0] + item_count = item_parts[1] + if not item_count.isnumeric() and item_count not in ["all", "half"] and not item_count.endswith('%'): + logging.debug(f'Invalid item_count "{item_count}" found, reverting to initial item_name "{item}"') + item_name = item - item_name = item_name[1:] + # if it's a category, validate that the category exists + if is_category: item_category_exists = len([item for item in DataValidation.item_table 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 - item = item.replace("|", "") - - 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.get("name"))) + 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"]: @@ -107,35 +103,30 @@ def checkItemNamesInRegionRequires(): if item.lower() == "or" or item.lower() == "and" or item == ")" or item == "(": continue else: - # if it's a category, validate that the category exists - if '@' in item: - item = item.replace("|", "") - item_parts = item.split(":") - item_name = item + is_category = '|@' in item + item: str = item.lstrip('|@').rstrip('|') + item_parts = item.rsplit(":", 1) + item_name = item - if len(item_parts) > 1: - item_name = item_parts[0] + if len(item_parts) > 1: + item_name = item_parts[0] + item_count = item_parts[1] + if not item_count.isnumeric() and item_count not in ["all", "half"] and not item_count.endswith('%'): + logging.debug(f'Invalid item_count "{item_count}" found, reverting to initial item_name "{item}"') + item_name = item - item_name = item_name[1:] + # if it's a category, validate that the category exists + if is_category: item_category_exists = len([item for item in DataValidation.item_table 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)) - continue - - item = item.replace("|", "") - - item_parts = item.split(":") - item_name = item + else: + item_exists = len([item.get("name") for item in DataValidation.item_table_with_events if item.get("name") == item_name]) > 0 - 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)) + 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"]: diff --git a/src/Rules.py b/src/Rules.py index 0c1c74c3..bb844f53 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -162,9 +162,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = # previous instance of this item was already processed continue - require_category = False - if '|@' in item: - require_category = True + require_category = '|@' in item item_base = item item: str = item.lstrip('|@$').rstrip('|') @@ -178,6 +176,11 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = item_name = item_parts[0].strip() item_count = item_parts[1].strip() + # If invalid count assume its actually part of the item name + if not item_count.isnumeric() and item_count not in ["all", "half"] and not item_count.endswith('%'): + item_name = item + item_count = "1" + total = 0 valid_items: list[str] = [] if require_category: From e6fff76a869e4fa3efd2a30382b1b72b96d57801 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:19:08 -0400 Subject: [PATCH 06/15] moved invalid count conversion to evaluate_nonnumeric_count --- src/DataValidation.py | 82 ++++++++++++++++++------------------------- src/Rules.py | 31 +++++++--------- 2 files changed, 48 insertions(+), 65 deletions(-) diff --git a/src/DataValidation.py b/src/DataValidation.py index 5be7ce2b..70eace16 100644 --- a/src/DataValidation.py +++ b/src/DataValidation.py @@ -22,41 +22,35 @@ class DataValidation(): @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 item in re.findall(r'\|[^|]+\|', location["requires"]): - if item.lower() == "or" or item.lower() == "and" or item == ")" or item == "(": - continue - else: - is_category = '|@' in item - item: str = item.lstrip('|@').rstrip('|') - item_parts = item.rsplit(":", 1) - item_name = 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 len(item_parts) > 1: - item_name = item_parts[0] - item_count = item_parts[1] - if not item_count.isnumeric() and item_count not in ["all", "half"] and not item_count.endswith('%'): - logging.debug(f'Invalid item_count "{item_count}" found, reverting to initial item_name "{item}"') - item_name = item + if not item_count.isnumeric() and item_count 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 if item_name in item.get('category', [])]) > 0 + # if it's a category, validate that the category exists + if is_category: + item_category_exists = len([item for item in DataValidation.item_table 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"))) + 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 + 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"))) + 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"]: @@ -92,6 +86,7 @@ def checkItemNamesInLocationRequires(): @staticmethod def checkItemNamesInRegionRequires(): + from .Rules import ITEM_REGEX for region_name in DataValidation.region_table: region = DataValidation.region_table[region_name] @@ -100,34 +95,27 @@ def checkItemNamesInRegionRequires(): if isinstance(region["requires"], str): # parse user written statement into list of each item - for item in re.findall(r'\|[^|]+\|', region["requires"]): - if item.lower() == "or" or item.lower() == "and" or item == ")" or item == "(": - continue - else: - is_category = '|@' in item - item: str = item.lstrip('|@').rstrip('|') - item_parts = item.rsplit(":", 1) - item_name = 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 len(item_parts) > 1: - item_name = item_parts[0] - item_count = item_parts[1] - if not item_count.isnumeric() and item_count not in ["all", "half"] and not item_count.endswith('%'): - logging.debug(f'Invalid item_count "{item_count}" found, reverting to initial item_name "{item}"') - item_name = item + if not item_count.isnumeric() and item_count 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 if item_name in item.get('category', [])]) > 0 + # if it's a category, validate that the category exists + if is_category: + item_category_exists = len([item for item in DataValidation.item_table 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)) + 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 + 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)) + 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"]: diff --git a/src/Rules.py b/src/Rules.py index 5b3545f4..f73a67a6 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -116,10 +116,10 @@ def evaluate_postfix(expr: str, location: dict) -> bool: return stack.pop() def set_rules(world: "ManualWorld", multiworld: MultiWorld, player: int): - def evaluate_nonnumeric_count(item_name: str, item_count: str, is_category: bool, area: dict) -> 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 int(item_count) + return item_name, int(item_count) items_counts = world.get_item_counts(player, only_progression=True) if is_category: @@ -128,16 +128,18 @@ def evaluate_nonnumeric_count(item_name: str, item_count: str, is_category: bool total_count = sum([items_counts.get(category_item["name"], 0) for category_item in category_items]) else: total_count = items_counts.get(item_name, 0) - if item_count == 'all': - return total_count + count = total_count elif item_count == 'half': - return int(total_count / 2) + count = int(total_count / 2) elif item_count.endswith('%') and len(item_count) > 1: percent = clamp(float(item_count[:-1]) / 100, 0, 1) - return math.ceil(total_count * percent) - - raise ValueError(f"Invalid item count `{item_name}` in {area}.") + 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: @@ -163,7 +165,7 @@ def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | if item_count.isnumeric(): count = int(item_count) else: - count = evaluate_nonnumeric_count(item_name, item_count, is_category, area) + item_name, count = evaluate_nonnumeric_count(match.group(0), item_name, item_count, is_category, area) if is_category: rule = rule_builder.rules.HasGroup(item_name, count) @@ -303,7 +305,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = # parse user written statement into list of each item for match in ITEM_REGEX.finditer(requires_list): item_base = match.group(0) - is_category = match.group(1) + is_category = bool(match.group(1)) item_name = match.group(2) item_count = match.group(3) @@ -315,22 +317,15 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = item_count = "1" item_count = item_count.lstrip(':') - # TODO deal with this post merge - # # If invalid count assume its actually part of the item name - # if not item_count.isnumeric() and item_count not in ["all", "half"] and not item_count.endswith('%'): - # item_name = item - # item_count = "1" - total = 0 + item_name, numeric_count = evaluate_nonnumeric_count(item_base, item_name, item_count, is_category, area) valid_items: list[str] = [] if is_category: # TODO replace loops with pre calculated categories list valid_items.extend([item["name"] for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]]) valid_items.extend([event["name"] for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]]) - numeric_count = evaluate_nonnumeric_count(item_name, item_count, True, area) else: valid_items.append(item_name) - numeric_count = evaluate_nonnumeric_count(item_name, item_count, False, area) for valid_item in valid_items: total += state.count(valid_item, player) From 80c9120b3ae260424ebcb8c8abdde47cd7e1134c Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:17:51 -0400 Subject: [PATCH 07/15] remove loops by using state.has instead --- src/Rules.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index f73a67a6..b915ec72 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -317,24 +317,16 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = item_count = "1" item_count = item_count.lstrip(':') - total = 0 item_name, numeric_count = evaluate_nonnumeric_count(item_base, item_name, item_count, is_category, area) - valid_items: list[str] = [] + if is_category: - # TODO replace loops with pre calculated categories list - valid_items.extend([item["name"] for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]]) - valid_items.extend([event["name"] for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]]) + found = state.has_group(item_name, player, numeric_count) else: - valid_items.append(item_name) - - for valid_item in valid_items: - total += state.count(valid_item, player) + found = state.has(item_name, player, numeric_count) - if total >= numeric_count: - requires_list = requires_list.replace(item_base, "1") - break - - if total < 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) From f78edcaf34468ab16c7abd53707033ddb27a3b38 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:23:44 -0400 Subject: [PATCH 08/15] add event items to item group so they can be included in @categories requirements --- src/Items.py | 10 +++++----- src/Locations.py | 12 +++++++++--- src/__init__.py | 13 +++++++++++-- src/data/events.json | 1 + 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/Items.py b/src/Items.py index 68c76776..574431e4 100644 --- a/src/Items.py +++ b/src/Items.py @@ -9,7 +9,7 @@ item_id_to_name: dict[int, str] = {} item_name_to_item: dict[str, dict] = {} -item_name_groups: dict[str, str] = {} +item_name_groups: dict[str, set[str]] = {} advancement_item_names: set[str] = set() lastItemId = -1 @@ -47,8 +47,8 @@ for c in item.get("category", []): if c not in item_name_groups: - item_name_groups[c] = [] - item_name_groups[c].append(item_name) + 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 @@ -57,8 +57,8 @@ 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] = [] - item_name_groups[group_name].append(item_name) + 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()} diff --git a/src/Locations.py b/src/Locations.py index d2ee209d..d09bd6aa 100644 --- a/src/Locations.py +++ b/src/Locations.py @@ -46,8 +46,9 @@ location_id_to_name: dict[int, str] = {} location_name_to_location: dict[str, dict[str, Any]] = {} -location_name_groups: dict[str, list[str]] = {} +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']}") @@ -56,8 +57,8 @@ for c in loc.get("category", []): if c not in location_name_groups: - location_name_groups[c] = [] - location_name_groups[c].append(loc_name) + location_name_groups[c] = set() + location_name_groups[c].add(loc_name) # location_id_to_name[None] = "__Manual Game Complete__" @@ -83,6 +84,11 @@ 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 ###################### diff --git a/src/__init__.py b/src/__init__.py index fd50ed83..39abae1e 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -10,7 +10,7 @@ from .Data import item_table, location_table, event_table, region_table, category_table from .Game import game_name, filler_item_name, starting_items from .Meta import world_description, world_webworld -from .Locations import location_id_to_name, location_name_to_id, location_name_to_location, location_name_groups, victory_names, event_name_to_event +from .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 @@ -68,6 +68,11 @@ class ManualWorld(World): victory_names = victory_names event_name_to_event = event_name_to_event + for group, events in event_name_groups.items(): + if group not in item_name_groups.keys(): + item_name_groups[group] = events + else: + item_name_groups[group] |= events # UT (the universal-est of trackers) can now generate without a YAML ut_can_gen_without_yaml = True @@ -259,8 +264,12 @@ def create_items(self): 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): + placed_pool.append(location.item) - real_pool = pool + precollected_items + 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) diff --git a/src/data/events.json b/src/data/events.json index 1e0e4f1e..2ff390fc 100644 --- a/src/data/events.json +++ b/src/data/events.json @@ -16,6 +16,7 @@ }, { "name": "X-Men Assemble", + "category": ["Right Side"], "requires": "((|Wolverine|) OR |X-23|) AND |Storm| and |Magneto:1|" }, { From 4f4ac2ae13c2272a605617253202348f8fd871c7 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:33:06 -0400 Subject: [PATCH 09/15] convert evaluate_nonnumeric_count to also use item_name_groups --- src/Rules.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index b915ec72..a3a2e2f4 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -123,9 +123,8 @@ def evaluate_nonnumeric_count(item_base: str, item_name: str, item_count: str, i items_counts = world.get_item_counts(player, only_progression=True) if is_category: - category_items = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]] - category_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]] - total_count = sum([items_counts.get(category_item["name"], 0) for category_item in category_items]) + items = world.item_name_groups[item_name] + total_count = sum([items_counts.get(item, 0) for item in items]) else: total_count = items_counts.get(item_name, 0) if item_count == 'all': From 9c3ce1c1a021ce0f6e1ae0e5a6876766badc8279 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:17:41 -0400 Subject: [PATCH 10/15] remove events from item group and use has_from_list instead --- src/Rules.py | 10 ++++++---- src/__init__.py | 6 +----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index a3a2e2f4..22064b66 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -123,7 +123,7 @@ def evaluate_nonnumeric_count(item_base: str, item_name: str, item_count: str, i items_counts = world.get_item_counts(player, only_progression=True) if is_category: - items = world.item_name_groups[item_name] + items = world.item_name_groups.get(item_name, set()).union(world.event_name_groups.get(item_name, set())) total_count = sum([items_counts.get(item, 0) for item in items]) else: total_count = items_counts.get(item_name, 0) @@ -153,7 +153,7 @@ def construct_rule_from_string(area: dict) -> "rule_builder.rules.Rule | None": def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | None": if not partial: return rule_builder.rules.True_() - rule = None + rule: Rule | None = None remaining = '' partial = partial.strip() if match := ITEM_REGEX.match(partial): @@ -167,7 +167,8 @@ def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | item_name, count = evaluate_nonnumeric_count(match.group(0), item_name, item_count, is_category, area) if is_category: - rule = rule_builder.rules.HasGroup(item_name, count) + items = world.item_name_groups.get(item_name, set()).union(world.event_name_groups.get(item_name, set())) + rule = rule_builder.rules.HasFromList(*items, count=count) else: rule = rule_builder.rules.Has(item_name, count) remaining = partial[len(match.group(0)):] @@ -319,7 +320,8 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = item_name, numeric_count = evaluate_nonnumeric_count(item_base, item_name, item_count, is_category, area) if is_category: - found = state.has_group(item_name, player, numeric_count) + items = world.item_name_groups.get(item_name, set()).union(world.event_name_groups.get(item_name, set())) + found = state.has_from_list(items, player, numeric_count) else: found = state.has(item_name, player, numeric_count) diff --git a/src/__init__.py b/src/__init__.py index 39abae1e..639112f1 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -68,11 +68,7 @@ class ManualWorld(World): victory_names = victory_names event_name_to_event = event_name_to_event - for group, events in event_name_groups.items(): - if group not in item_name_groups.keys(): - item_name_groups[group] = events - else: - item_name_groups[group] |= events + event_name_groups = event_name_groups # UT (the universal-est of trackers) can now generate without a YAML ut_can_gen_without_yaml = True From e57ac2fa665efa6482e726630ffe38b43e7a86ad Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:20:16 -0400 Subject: [PATCH 11/15] category created by events are still valid --- src/DataValidation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DataValidation.py b/src/DataValidation.py index 70eace16..a5f79c34 100644 --- a/src/DataValidation.py +++ b/src/DataValidation.py @@ -40,7 +40,7 @@ def checkItemNamesInLocationRequires(): # if it's a category, validate that the category exists if is_category: - item_category_exists = len([item for item in DataValidation.item_table if item_name in item.get('category', [])]) > 0 + item_category_exists = len([item for item in DataValidation.item_table_with_events if item_name in item.get('category', [])]) > 0 if not item_category_exists: raise ValidationError("Item category %s is required by location %s but is misspelled or does not exist." % (item_name, location.get("name"))) @@ -106,7 +106,7 @@ def checkItemNamesInRegionRequires(): # if it's a category, validate that the category exists if is_category: - item_category_exists = len([item for item in DataValidation.item_table if item_name in item.get('category', [])]) > 0 + item_category_exists = len([item for item in DataValidation.item_table_with_events if item_name in item.get('category', [])]) > 0 if not item_category_exists: raise ValidationError("Item category %s is required by region %s but is misspelled or does not exist." % (item_name, region_name)) From 90e08759d21cd2ae100237ee7749df05b6cd5c6e Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:09:12 -0400 Subject: [PATCH 12/15] pre combine the item and event name groups --- src/Rules.py | 9 +++------ src/__init__.py | 6 ++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index 22064b66..7deabc06 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -123,8 +123,7 @@ def evaluate_nonnumeric_count(item_base: str, item_name: str, item_count: str, i items_counts = world.get_item_counts(player, only_progression=True) if is_category: - items = world.item_name_groups.get(item_name, set()).union(world.event_name_groups.get(item_name, set())) - total_count = sum([items_counts.get(item, 0) for item in items]) + 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 == 'all': @@ -167,8 +166,7 @@ def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | item_name, count = evaluate_nonnumeric_count(match.group(0), item_name, item_count, is_category, area) if is_category: - items = world.item_name_groups.get(item_name, set()).union(world.event_name_groups.get(item_name, set())) - rule = rule_builder.rules.HasFromList(*items, count=count) + 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)):] @@ -320,8 +318,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int = item_name, numeric_count = evaluate_nonnumeric_count(item_base, item_name, item_count, is_category, area) if is_category: - items = world.item_name_groups.get(item_name, set()).union(world.event_name_groups.get(item_name, set())) - found = state.has_from_list(items, player, numeric_count) + 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) diff --git a/src/__init__.py b/src/__init__.py index 639112f1..cbb223e2 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -69,6 +69,12 @@ class ManualWorld(World): 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 From 4ff71507dcca1810d2cdaffe91b9ec53ae8a6bd1 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:33:54 -0400 Subject: [PATCH 13/15] name the area earlier so functions works correctly --- src/Rules.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index 7deabc06..eb71dd9e 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -410,7 +410,10 @@ def fullLocationOrRegionCheck(state: CollectionState, area: dict): 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, {}) - rb_rule = construct_rule_from_string(regionMap[region]) + 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) @@ -419,10 +422,7 @@ def fullLocationOrRegionCheck(state: CollectionState, area: dict): rb_rule = rb_rule & rb_extra_rule world.set_rule(world.get_entrance(exitRegion.name), rb_rule) else: - def fullRegionCheck(state: CollectionState, region=regionMap[region], region_name=exitRegion.name): - region['name'] = region_name - region['is_region'] = True - + def fullRegionCheck(state: CollectionState, region=area): return fullLocationOrRegionCheck(state, region) add_rule(world.get_entrance(exitRegion.name), fullRegionCheck) From 0bad0ce7da4dff539390885b47880a3e5832949d Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:59:33 -0400 Subject: [PATCH 14/15] fix recursively_tokenize_manual_rule stopping after the first function --- src/Rules.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Rules.py b/src/Rules.py index eb71dd9e..17c78f06 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -207,6 +207,7 @@ def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | return None rule = rule_class(*func_args) + remaining = partial[len(match.group(0)):] elif partial[0] == "(": inner = '' queue = list(partial[1:]) From 70a8d9e40537b2df958b0d94aba7e6daf363e863 Mon Sep 17 00:00:00 2001 From: nicopop <6759630+nicopop@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:32:06 -0400 Subject: [PATCH 15/15] protect Functions when removing parenthesis in recursively_tokenize_manual_rule --- src/Rules.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Rules.py b/src/Rules.py index 17c78f06..8f642199 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -202,13 +202,23 @@ def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | if rule is None: if not rule_class: - print(f'Warning: Could not find Rule implmenentation of {func_name}.') + print(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: dict[int, str] = {} + id: int = 0 + for match in FUNCTION_REGEX.finditer(partial): + if match.group(0) not in partial: + # already done all of them + continue + func_founds[id] = match.group(0) + # looks like : {{Function#0}} + partial = partial.replace(match.group(0), f"{{{{Function#{id}}}}}") + id += 1 inner = '' queue = list(partial[1:]) stack = 1 @@ -220,8 +230,11 @@ def recursively_tokenize_manual_rule(partial: str) -> "rule_builder.rules.Rule | stack -= 1 else: inner += c - rule = recursively_tokenize_manual_rule(inner) remaining = "".join(queue) + for id, func in func_founds.items(): + remaining = remaining.replace(f"{{{{Function#{id}}}}}", func) + inner = inner.replace(f"{{{{Function#{id}}}}}", func) + rule = recursively_tokenize_manual_rule(inner) else: print(f'Could not convert {partial} into a Rule') return None