diff --git a/Generate.py b/Generate.py index 52babdf18839..532e91a081f4 100644 --- a/Generate.py +++ b/Generate.py @@ -378,33 +378,157 @@ def roll_linked_options(weights: dict) -> dict: return weights -def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict: +def compare_results( + yaml_value: Union[str, int, float, bool, dict, list], + trigger_value: Union[str, int, float, bool, dict, list], + comparator: str): + if yaml_value is None: + return False + if (isinstance(yaml_value, str|bool|int|float) and isinstance(trigger_value, str|bool|int|float)): + yaml_value = str(yaml_value).lower() + trigger_value = str(trigger_value).lower() + if comparator == "=": + return yaml_value == trigger_value + if comparator == "!=": + return yaml_value != trigger_value + try: + yaml_value = int(yaml_value) + trigger_value = int(trigger_value) + except ValueError: + raise Exception("Value of option_name and option_result must be integers if comparison is not = or !=") + if comparator == "<": + return yaml_value < trigger_value + if comparator == "<=": + return yaml_value <= trigger_value + if comparator == ">": + return yaml_value > trigger_value + if comparator == ">=": + return yaml_value >= trigger_value + + +def compare_triggers(option_set: dict, currently_targeted_weights: dict) -> bool: + result = True + advanced = copy.deepcopy(option_set["option_advanced"]) + result = result and compare_results(currently_targeted_weights[advanced[0][0]], advanced[0][2], advanced[0][1]) + for x in range(1, len(advanced), 2): + if str(advanced[x]).lower() in ["|", "1", "or"]: + if result: + return True + result = True + elif str(advanced[x]).lower() in ["&", "0", "and"]: + if not result: + continue + else: + raise Exception(f"Unions must be chosen from: [&, 0, 'and', |, 1, 'or']. Please check trigger entry {x+1}") + entry = advanced[x+1] + result = result and compare_results(currently_targeted_weights[entry[0]], entry[2], entry[1]) + return result + + +def handle_random_range_in_triggers(value: str) -> int: + if len(value.split("-")) == 4: + value = value.split("-") + value_min = int(value[2]) + value_max = int(value[3]) + result = random.randrange(value_min, value_max) + else: + value = value.split("-") + value_min = int(value[3]) + value_max = int(value[4]) + if value[2] == "low": + result = int(round(random.triangular(value_min, value_max, value_min))) + elif value[2] == "medium": + result = int(round(random.triangular(value_min, value_max))) + elif value[2] == "high": + result = int(round(random.triangular(value_min, value_max, value_max))) + else: + raise Exception(f"Invalid weighting in random-range-x-min-max, " + f"x must be low, medium, or high. It is: {value[2]}") + return result + + +def roll_triggers(weights: dict, triggers: list, valid_keys: set, type_hints: dict) -> dict: weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights["_Generator_Version"] = Utils.__version__ for i, option_set in enumerate(triggers): try: + if "option_advanced" not in option_set: + if "option_compare" in option_set: + option_set["option_advanced"] = [[option_set["option_name"], option_set["option_compare"], + option_set["option_result"]]] + else: + split_result = option_set["option_result"] + is_range = False + if isinstance(split_result, str): + split_result = split_result.split("_") + if len(split_result) == 3 and split_result[0] == "range": + is_range = True + if is_range: + try: + split_result[1] = int(split_result[1]) + split_result[2] = int(split_result[2]) + except ValueError: + raise Exception(f"Invalid argument in option_result's range: {option_set['option_result']}") + option_set["option_advanced"] = [[option_set["option_name"], ">=", split_result[1]], + "&", + [option_set["option_name"], "<=", split_result[2]]] + else: + option_set["option_advanced"] = [[option_set["option_name"], "=", option_set["option_result"]]] currently_targeted_weights = weights category = option_set.get("option_category", None) if category: currently_targeted_weights = currently_targeted_weights[category] - key = get_choice("option_name", option_set) - if key not in currently_targeted_weights: - logging.warning(f'Specified option name {option_set["option_name"]} did not ' - f'match with a root option. ' - f'This is probably in error.') - trigger_result = get_choice("option_result", option_set) - result = get_choice(key, currently_targeted_weights) - currently_targeted_weights[key] = result - if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)): + advanced = option_set["option_advanced"] + if len(advanced) % 2 != 1: + raise Exception("option_advanced has an illegal number of entries. Please check trigger and fix.") + for x in range(0, len(advanced), 2): + if type_hints is not None and advanced[x][0] in type_hints: + option = type_hints[advanced[x][0]] + if advanced[x][0] in currently_targeted_weights: + if not option.supports_weighting: + result = option.from_any(currently_targeted_weights[advanced[x][0]]) + else: + result = option.from_any(get_choice(advanced[x][0], currently_targeted_weights)) + else: + result = option.from_any(option.default) + else: + result = get_choice(advanced[x][0], currently_targeted_weights) + if advanced[x][0] not in currently_targeted_weights: + logging.warning(f"Specified option name {advanced[x][0]} did not " + f"match with a root option. " + f"This is probably in error.") + if isinstance(result, str): + if result.startswith("random-range-") and len(result.split("-")) in [4, 5]: + result = handle_random_range_in_triggers(result) + if hasattr(result, "name_lookup"): + if result.name_lookup != {}: + currently_targeted_weights[advanced[x][0]] = result.current_key + else: + currently_targeted_weights[advanced[x][0]] = result.value + else: + currently_targeted_weights[advanced[x][0]] = result + if len(advanced[x]) == 2: + advanced[x].insert(1, "=") + if len(advanced[x]) == 3: + if advanced[x][1] not in ["<", ">", "=", "!="]: + raise Exception(f"option_advanced has an illegal comparator in block {x}. " + f"Please check trigger and fix.") + else: + raise Exception(f"options_advanced is malformed. " + f"Block {x} should have either 2 or 3 entries, but had {len(advanced[x])}.\n") + if (compare_triggers(option_set, currently_targeted_weights) + and roll_percentage(get_choice("percentage", option_set, 100))): for category_name, category_options in option_set["options"].items(): currently_targeted_weights = weights if category_name: currently_targeted_weights = currently_targeted_weights[category_name] - update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"]) - valid_keys.add(key) + update_weights(currently_targeted_weights, category_options, "Triggered", + f"Trigger {i + 1}") + for x in range(0, len(advanced), 2): + valid_keys.add(advanced[x][0]) except Exception as e: raise ValueError(f"Your trigger number {i + 1} is invalid. " - f"Please fix your triggers.") from e + f"Please fix your triggers. {enumerate(triggers)}") from e return weights @@ -433,7 +557,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b valid_keys = set() if "triggers" in weights: - weights = roll_triggers(weights, weights["triggers"], valid_keys) + weights = roll_triggers(weights, weights["triggers"], valid_keys, None) requirements = weights.get("requires", {}) if requirements: @@ -476,7 +600,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}") if "triggers" in game_weights: - weights = roll_triggers(weights, game_weights["triggers"], valid_keys) + weights = roll_triggers(weights, game_weights["triggers"], valid_keys, world_type.options_dataclass.type_hints) game_weights = weights[ret.game] ret.name = get_choice('name', weights) diff --git a/worlds/generic/docs/triggers_en.md b/worlds/generic/docs/triggers_en.md index b751b8a3ec01..3c0c7e4cba50 100644 --- a/worlds/generic/docs/triggers_en.md +++ b/worlds/generic/docs/triggers_en.md @@ -15,12 +15,14 @@ created using entirely triggers and plando. For more information on plando, you can reference the [general plando guide](/tutorial/Archipelago/plando/en) or the [A Link to the Past plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en). -## Trigger use +## Normal Trigger use Triggers may be defined in either the root or in the relevant game sections. Generally, the best place to do this is the -bottom of the YAML for clear organization. +bottom of the YAML for clear organization. Triggers will evaluate in the order they are written, so later triggers will use +options set by earlier triggers, and later results will take precedence over earlier results. +Warning: Triggers placed in the root of the yaml, rather than under a specific game, will not be able to use 'random' options. -Each trigger consists of four parts: +Each trigger consists of four parts plus one optional part: - `option_category` specifies the section which the triggering option is defined in. - Example: `A Link to the Past` - This is the category the option is located in. If the option you're triggering off of is in root then you @@ -28,10 +30,11 @@ Each trigger consists of four parts: - `option_name` specifies the name of the triggering option. - Example: `shop_item_slots` - This can be any option from any category defined in the YAML file in either root or a game section. -- `option_result` specifies the value of the option that activates this trigger. - - Example: `15` - - Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple - results, you would need multiple triggers for this. +- `option_result` specifies the value of the option that activates this trigger, can specify a valid range for numeric results with range_x_y. + - Examples: `15` or `range_2_7` + - Each normal trigger must be used for exactly one option result or inclusive range. If you would like the same thing to occur with multiple + conditions, you would need multiple triggers for this, or use the advanced trigger options outlined below in "Advanced + Trigger Options". - `options` is where you define what will happen when the trigger activates. This can be something as simple as ensuring another option also gets selected or placing an item in a certain location. It is possible to have multiple things happen in this section. @@ -50,9 +53,14 @@ The general format is: desired result ``` +- `option_compare` is an optional 5th part which specifies how you wish to compare the named option and the result, + this defaults to "=" if you do not include this option. + - Example: `<` + - Values can be any item from this list: ['<', '<=', '>', '>=', !=', '='] + ### Examples -The above examples all together will end up looking like this: +The above examples all together could end up looking like this: ```yaml triggers: @@ -82,9 +90,25 @@ For example: In this example, if your world happens to roll SpecificKeycards, then your game will also start in inverted. +If you wish for the trigger to activate when under conditions OTHER than an exact match, you should include option_compare: + + ```yaml + triggers: + - option_category: A Link to the Past + option_name: shop_item_slots + option_result: 15 + option_compare: ">" + options: + A Link to the Past: + start_inventory: + Rupees(300): 2 + ``` + +In this example, if there are MORE than 15 shop item slots, you'll be granted 600 rupees at the beginning. + It is also possible to use imaginary values in options to trigger specific settings. You can use these made-up values in either your main options or to trigger from another trigger. Currently, this is the only way to trigger on "setting 1 -AND setting 2". +AND setting 2" without using advanced trigger options. For example: @@ -160,3 +184,86 @@ Super Metroid: In this example, if the `start_location` option rolls `landing_site`, only a starting hint for Morph Ball will be created. If `aqueduct` is rolled, a starting hint for Gravity Suit will also be created alongside the hint for Morph Ball. + +## Advanced Trigger Options + +If you feel that you need more control, you can instead create a trigger using "option_category: game" and "options:" +as in the previous sections, but replace option_name, option_result, and option_compare with 'option_advanced: list of options'. +This allows for comparing combinations of settings (A & B OR C & D). + +Each entry in option_advanced is a list made up of the option name, and the value you wish to compare that value to, with an +optional comparison entry between them (this defaults to = if you do not include it.) +Format: [option name, value to compare option to] or [option name, comparitor, value to compare option to]. + +If you want to trigger off of multiple options, you must specify whether you want to require BOTH options to be true (&, 0, 'and') +or to only require that EITHER be true (|, 1, 'or'). +'And' always takes precedence over 'or', so A & B & C | D & E | F is the same as (A & B & C) | (D & E) | F + +The format will always be alternating conditions and 'unitors' (&/|), and each entry should be prefaced with a '-' in the following +format: + ```yaml +game name: + game options + . + . + . + triggers: + - option_category: "game name" + option_advanced: + - ["option_name1", "comparison1", "result1"] + - "unitor1" + - ["option_name2", "comparison2", "result2"] + - "unitor2" + . + . + . + - ["option_nameN", "comparisonN", "resultN"] + options: + "game name": + "option_to_change_1": value + "option_to_change_2": value + . + . + . + - option_category: "game_name" + . + . + . + ``` + + +Here is an example: + ```yaml +A Link to the Past: + goal: "ganon" + crystals_needed_for_gt: "random-range-0-7" + crystals_needed_for_ganon: "random-range-0-7" + swordless: + false: 10 + true: 10 + bombless_start: + false: 10 + true: 10 + retro_bow: + false: 10 + true: 10 + triggers: + option_category: "A Link to the Past" + option_advanced: + - ["crystals_needed_for_gt", ">", 5] + - "&" + - ["crystals_needed_for_ganon", "<", 4] + - "|" + - ["swordless", true] + - "&" + - ["bombless_start", true] + - "&" + - ["retro_bow", true] + options: + A Link to the Past: + goal: "crystals" + ``` + +This will change the goal to "crystals" if either of the following conditions are met: +1. crystals_needed_for_gt rolls higher than 5 AND crystals_needed_for_ganon rolls lower than 4 +2. swordless, bombless_start, and retro_bow ALL roll true.