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

+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}')