diff --git a/docs/syntax/requires.md b/docs/syntax/requires.md index fc170489..e473e43f 100644 --- a/docs/syntax/requires.md +++ b/docs/syntax/requires.md @@ -33,6 +33,7 @@ For example, from the example above about Link to the Past and Thieves Town in t ### Additional Examples of Boolean Logic Boss 1 Requires Ladder and Gloves, OR Sword and Shield, OR Bow and Quiver and Arrow (separate items): a simple case of various successful item sets. It's a few sets of ANDs separated by ORs. + ```json { "name": "Boss 1", @@ -41,6 +42,7 @@ Boss 1 Requires Ladder and Gloves, OR Sword and Shield, OR Bow and Quiver and Ar ``` Boss 2 simply requires one heart, a way to strike it (Sword, Spear or Club) and a way to dodge it (Double Jump, Dash or Slide): we're looking at different sets, and picking one item from which. It's many ORs inside a big set of ANDs. + ```json { "name": "Boss 2", @@ -49,6 +51,7 @@ Boss 2 simply requires one heart, a way to strike it (Sword, Spear or Club) and ``` Now, say the final boss is a big dragon with a glaring weakness to Blizzard. However, if you don't have blizzard, you will need a spear for its reach and a way to dodge it, which is one of the three mobility from before. This is an OR (the mobility), inside an AND (Spear and Mobility), inside an OR (Blizzard it or fight it legitimately). Layered logic is as such: + ```json { "name": "Final Boss", @@ -68,6 +71,7 @@ The way to do this is a short suffix added to the end of any required item name Now that we know how to require multiple of an item, we can revise our Boss 2 example from above to make the boss a little easier to handle in-logic: > Boss 2 simply requires **FIVE hearts**, a way to strike it (Sword, Spear or Club) and a way to dodge it (Double Jump, Dash or Slide): we're looking at different sets, and picking one item from which. It's many ORs inside a big set of ANDs. +> > ```json >{ > "name": "Boss 2", @@ -125,7 +129,6 @@ Checks if you've collected the specificed value of a value-based item. For Example, `{ItemValue(Coins:12)}` will check if the player has collect at least 12 coins worth of items - ### `OptOne(ItemName)` Requires an item only if that item exists. Useful if an item might have been disabled by a yaml option. @@ -136,7 +139,6 @@ Takes an entire requires string, and applies the above check to each item inside For example, `requires: "{OptAll(|DisabledItem| and |@CategoryWithModifedCount:10|)} and |other items|"` will be transformed into `"|DisabledItem:0| and |@CategoryWithModifedCount:2| and |other items|"` - ### `YamlEnabled(option_name)` and `YamlDisabled(option_name)` These allow you to check yaml options within your logic. @@ -166,3 +168,29 @@ You can even combine the two in complex ways "name": "This is probably a region", "requires": "({YamlEnabled(easy_mode)} and |Gravity|) or ({YamlDisabled(easy_mode)} and |Jump| and |Blizzard| and |Water|)" } +``` + +### `YamlCompare(option_name comparator_symbol value)` + +Verify that the result of the option called _option_name_'s value compared using the _comparator_symbol_ with the requested _value_ + +The comparator symbol can be any of the following: `== or =, !=, >=, <=, <, >` + +The value can be of any type that your option supports + +- Range: integer aka number +- Range with values aka NamedRange: integer or one of the value name in "values" +- Choice: either numerical or string representation of a value in the option's "values" +- Choice with allow_custom_value: either numerical or string representation of a value in the option's "values" or a custom string +- Toggle: a boolean value represented by any of the following not case sensitive: + - True: "true", "on", 1 + - False: "false", "off", 0 + +The folowing example would check that the player.yaml value of the range option Example_Range is bigger than 5 or that the `Item A` item is present: + +```json +{ + "name": "Example Region", + "requires": "|Item A| or {YamlCompare(Example_Range > 5)}" +} +``` diff --git a/src/Rules.py b/src/Rules.py index 7d7dab37..a22ca296 100644 --- a/src/Rules.py +++ b/src/Rules.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Optional from enum import IntEnum +from operator import eq, ge, le from .Regions import regionMap from .hooks import Rules @@ -8,6 +9,7 @@ from BaseClasses import MultiWorld, CollectionState from worlds.AutoWorld import World from worlds.generic.Rules import set_rule, add_rule +from Options import Choice, Toggle, Range, NamedRange import re import math @@ -493,3 +495,114 @@ def YamlEnabled(world: "ManualWorld", multiworld: MultiWorld, state: CollectionS def YamlDisabled(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, param: str) -> bool: """Is a yaml option disabled?""" return not is_option_enabled(multiworld, player, param) + +def YamlCompare(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, 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 + diff --git a/src/data/locations.json b/src/data/locations.json index c10e9903..afbcf69e 100644 --- a/src/data/locations.json +++ b/src/data/locations.json @@ -4,17 +4,17 @@ { "name": "Beat the Game - Ryu", "category": ["Starter Team", "Left Side"], - "requires": "|Ryu|" + "region": "ExampleRegion" }, { "name": "Beat the Game - Chun-Li", "category": ["Starter Team", "Left Side"], - "requires": "|Chun-Li|" + "requires": "|Chun-Li| or {YamlCompare(Example_Choice == 1)}" }, { "name": "Beat the Game - Akuma", "category": ["Starter Team", "Left Side"], - "requires": "|Akuma|" + "requires": "|Akuma| or {YamlCompare(Example_Toggle != True)}" }, { diff --git a/src/data/regions.json b/src/data/regions.json index c2126abd..35588238 100644 --- a/src/data/regions.json +++ b/src/data/regions.json @@ -2,6 +2,7 @@ "$schema": "https://github.com/ManualForArchipelago/Manual/raw/main/schemas/Manual.regions.schema.json", "ExampleRegion":{ "connects_to": ["Second Region"], + "requires": "|Ryu| or {YamlCompare(Example_Range >= example )}", "starting": true }, "Second Region": {