Skip to content

Commit 5f1c595

Browse files
committed
Move Predefined functions into non-hook Rules.py, Add YamlEnabled/Disabled functions, Add documentation for Predefined functions
1 parent fa569f2 commit 5f1c595

File tree

4 files changed

+178
-119
lines changed

4 files changed

+178
-119
lines changed

docs/syntax/requires.md

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,45 +12,45 @@ Okay, so we know what requires are. Let's talk about the different ways you can
1212

1313
## Boolean Logic (AND/OR)
1414

15-
**Boolean logic is the default way to write requires in Manual.** It's called "boolean logic" because you're writing your logic much like you'd describe it normally: with a series of AND/OR combos.
15+
**Boolean logic is the default way to write requires in Manual.** It's called "boolean logic" because you're writing your logic much like you'd describe it normally: with a series of AND/OR combos.
1616

1717
For example, from the example above about Link to the Past and Thieves Town in the Dark World, let's assume that the first chest location in the dungeon has no additional requirements. So, we'd describe our logic for that first chest location as being "Moon Pearl and (either (Hammer and Power Glove) or Titan's Mitt)", same as the region itself. In Manual's boolean logic syntax, that would be:
1818

1919
```json
20-
{
20+
{
2121
"name": "First chest in Thieves Town",
2222
"requires": "|Moon Pearl| and ((|Hammer| and |Power Glove|) or |Titan's Mitt|)"
2323
}
2424
```
2525

26-
**You use `|pipes|` around item names and `(parentheses)` around your layers of nesting, if needed.**
26+
**You use `|pipes|` around item names and `(parentheses)` around your layers of nesting, if needed.**
2727

28-
- Pipes tell Manual where to look for entire item names.
29-
- Parentheses tell Manual exactly how you're grouping your logic, since there's a difference between "Hammer and Power Glove or Titan's Mitt" and "(Hammer and Power Glove) or Titan's Mitt".
28+
- Pipes tell Manual where to look for entire item names.
29+
- Parentheses tell Manual exactly how you're grouping your logic, since there's a difference between "Hammer and Power Glove or Titan's Mitt" and "(Hammer and Power Glove) or Titan's Mitt".
3030
- The former essentially evaluates to "Hammer and either Power Glove or Titan's Mitt", while the latter is very explicit about what the logic should be and evaluates correctly.
3131
- There's no theoretical limit to how many parentheses you can use, but try to not get past the practical limit of how many sets of parentheses you can reliably keep track of.
3232

3333
### Additional Examples of Boolean Logic
3434

3535
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.
3636
```json
37-
{
37+
{
3838
"name": "Boss 1",
3939
"requires": "(|Ladder| and |Gloves|) or (|Sword| and |Shield|) or (|Bow| and |Quiver| and |Arrow|)"
4040
}
4141
```
4242

4343
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.
4444
```json
45-
{
45+
{
4646
"name": "Boss 2",
4747
"requires": "|Heart| and (|Sword| or |Spear| or |Club|) and (|Double Jump| or |Dash| or |Slide|)"
4848
}
4949
```
5050

5151
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:
5252
```json
53-
{
53+
{
5454
"name": "Final Boss",
5555
"requires": "|Blizzard| or (|Spear| and (|Double Jump| or |Dash| or |Slide|))",
5656
"victory": true
@@ -61,15 +61,15 @@ Now, say the final boss is a big dragon with a glaring weakness to Blizzard. How
6161

6262
As demonstrated in the [Making Items: Count](making/items.md#count) docs, you can configure an item to have more than one copy of that item in the world's item pool. Sometimes, you want to use multiple copies of an item as a requirement for accessing a location or region, and Manual supports this as well.
6363

64-
The way to do this is a short suffix added to the end of any required item name separated by a colon, like this: `|Coin:25|`.
64+
The way to do this is a short suffix added to the end of any required item name separated by a colon, like this: `|Coin:25|`.
6565

6666
- That will tell Manual that the location/region requires 25 of that Coin item.
6767

6868
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:
6969

7070
> 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.
7171
> ```json
72-
>{
72+
>{
7373
> "name": "Boss 2",
7474
> "requires": "|Heart:5| and (|Sword| or |Spear| or |Club|) and (|Double Jump| or |Dash| or |Slide|)"
7575
>}
@@ -106,13 +106,55 @@ The way to do this is using curly braces around the function name that you want
106106
- Note the lack of pipes (`|`). Functions are processed entirely differently than items/categories used as requirements.
107107
- Doing this will tell Manual that the function will either return a requires string to be processed, or will return true/false based on whether this requirement was met.
108108
109-
Requirement functions can have no function arguments, or have any number of function arguments separated by commas.
109+
Requirement functions can have no function arguments, or have any number of function arguments separated by commas.
110110
111111
- Example with no function arguments: https://github.com/ManualForArchipelago/Manual/blob/main/src/hooks/Rules.py#L8-L15.
112112
- Example with one argument, add str arguments to the end of the function for more: https://github.com/ManualForArchipelago/Manual/blob/main/src/hooks/Rules.py#L17-L24
113113
114-
Additionally, those functions can themselves return a dynamically-created requires string, which would then be processed normally in the spot where the function call was.
114+
Additionally, those functions can themselves return a dynamically-created requires string, which would then be processed normally in the spot where the function call was.
115115
116116
- Example of a returned requires string: https://github.com/ManualForArchipelago/Manual/blob/main/src/hooks/Rules.py#L26-L29
117117
118-
\* By default, the only two generic requirement functions that we provide are the OptOne and OptAll, which are both focused on allowing a required item or items to pass the requirement even if the item(s) have been disabled through defined category options. The rest of the functions in the Rules hook file are examples.
118+
\* By default, the only two generic requirement functions that we provide are the OptOne and OptAll, which are both focused on allowing a required item or items to pass the requirement even if the item(s) have been disabled through defined category options. The rest of the functions in the Rules hook file are examples.
119+
120+
## Bundled functions
121+
122+
Manual comes with some helpful functions built in:
123+
124+
### `OptOne(ItemName)`
125+
126+
Requires an item only if that item exists. Useful if an item might have been disabled by a yaml option.
127+
128+
### `OptAll(ItemName)`
129+
130+
I'm not sure how to document this function, nico pls help
131+
132+
### `YamlEnabled(option_name)` and `YamlDisabled(option_name)`
133+
134+
These allow you to check yaml options within your logic.
135+
136+
You might use this to allow glitches
137+
138+
```json
139+
{
140+
"name": "Item on Cliff",
141+
"requires": "|Double Jump| or {YamlEnabled(allow_hard_glitches)}"
142+
}
143+
```
144+
145+
Or make key items optional
146+
147+
```json
148+
{
149+
"name": "Hidden Item in Pokemon",
150+
"requires": "|Itemfinder| or {YamlDisabled(require_itemfinder)}"
151+
}
152+
```
153+
154+
You can even combine the two in complex ways
155+
156+
```json
157+
{
158+
"name": "This is probably a region",
159+
"requires": "({YamlEnabled(easy_mode)} and |Gravity|) or ({YamlDisabled(easy_mode)} and |Jump| and |Blizzard| and |Water|)"
160+
}

src/Rules.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from typing import TYPE_CHECKING
1+
from typing import TYPE_CHECKING, Optional
22
from worlds.generic.Rules import set_rule
33
from .Regions import regionMap
44
from .hooks import Rules
55
from BaseClasses import MultiWorld, CollectionState
6-
from .Helpers import clamp, is_item_enabled
6+
from .Helpers import clamp, is_item_enabled, get_items_with_value, is_option_enabled
7+
from worlds.AutoWorld import World
78

89
import re
910
import math
@@ -84,7 +85,15 @@ def checkRequireStringForArea(state: CollectionState, area: dict):
8485
func_args = item[1].split(",")
8586
if func_args == ['']:
8687
func_args.pop()
87-
func = getattr(Rules, func_name)
88+
89+
func = globals().get(func_name)
90+
91+
if func is None:
92+
func = getattr(Rules, func_name)
93+
94+
if not callable(func):
95+
raise ValueError(f"Invalid function `{func_name}` in {area}.")
96+
8897
result = func(world, multiworld, state, player, *func_args)
8998
if isinstance(result, bool):
9099
requires_list = requires_list.replace("{" + func_name + "(" + item[1] + ")}", "1" if result else "0")
@@ -261,3 +270,112 @@ def allRegionsAccessible(state):
261270

262271
# Victory requirement
263272
multiworld.completion_condition[player] = lambda state: state.has("__Victory__", player)
273+
274+
def ItemValue(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, args: str):
275+
"""When passed a string with this format: 'valueName:int',
276+
this function will check if the player has collect at least 'int' valueName worth of items\n
277+
eg. {ItemValue(Coins:12)} will check if the player has collect at least 12 coins worth of items
278+
"""
279+
280+
args_list = args.split(":")
281+
if not len(args_list) == 2 or not args_list[1].isnumeric():
282+
raise Exception(f"ItemValue needs a number after : so it looks something like 'ItemValue({args_list[0]}:12)'")
283+
args_list[0] = args_list[0].lower().strip()
284+
args_list[1] = int(args_list[1].strip())
285+
286+
if not hasattr(world, 'item_values_cache'): #Cache made for optimization purposes
287+
world.item_values_cache = {}
288+
289+
if not world.item_values_cache.get(player, {}):
290+
world.item_values_cache[player] = {
291+
'state': {},
292+
'count': {},
293+
}
294+
295+
if (args_list[0] not in world.item_values_cache[player].get('count', {}).keys()
296+
or world.item_values_cache[player].get('state') != dict(state.prog_items[player])):
297+
#Run First Time or if state changed since last check
298+
existing_item_values = get_items_with_value(world, multiworld, args_list[0])
299+
total_Count = 0
300+
for name, value in existing_item_values.items():
301+
count = state.count(name, player)
302+
if count > 0:
303+
total_Count += count * value
304+
world.item_values_cache[player]['count'][args_list[0]] = total_Count
305+
world.item_values_cache[player]['state'] = dict(state.prog_items[player]) #save the current gotten items to check later if its the same
306+
return world.item_values_cache[player]['count'][args_list[0]] >= args_list[1]
307+
308+
309+
# Two useful functions to make require work if an item is disabled instead of making it inaccessible
310+
def OptOne(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, item: str, items_counts: Optional[dict] = None):
311+
"""Check if the passed item (with or without ||) is enabled, then this returns |item:count|
312+
where count is clamped to the maximum number of said item in the itempool.\n
313+
Eg. requires: "{OptOne(|DisabledItem|)} and |other items|" become "|DisabledItem:0| and |other items|" if the item is disabled.
314+
"""
315+
if item == "":
316+
return "" #Skip this function if item is left blank
317+
if not items_counts:
318+
items_counts = world.get_item_counts()
319+
320+
require_type = 'item'
321+
322+
if '@' in item[:2]:
323+
require_type = 'category'
324+
325+
item = item.lstrip('|@$').rstrip('|')
326+
327+
item_parts = item.split(":")
328+
item_name = item
329+
item_count = '1'
330+
331+
if len(item_parts) > 1:
332+
item_name = item_parts[0]
333+
item_count = item_parts[1]
334+
335+
if require_type == 'category':
336+
if item_count.isnumeric():
337+
#Only loop if we can use the result to clamp
338+
category_items = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]]
339+
category_items_counts = sum([items_counts.get(category_item["name"], 0) for category_item in category_items])
340+
item_count = clamp(int(item_count), 0, category_items_counts)
341+
return f"|@{item_name}:{item_count}|"
342+
elif require_type == 'item':
343+
if item_count.isnumeric():
344+
item_current_count = items_counts.get(item_name, 0)
345+
item_count = clamp(int(item_count), 0, item_current_count)
346+
return f"|{item_name}:{item_count}|"
347+
348+
# OptAll check the passed require string and loop every item to check if they're enabled,
349+
def OptAll(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, requires: str):
350+
"""Check the passed require string and loop every item to check if they're enabled,
351+
then returns the require string with items counts adjusted using OptOne\n
352+
eg. requires: "{OptAll(|DisabledItem| and |@CategoryWithModifedCount:10|)} and |other items|"
353+
become "|DisabledItem:0| and |@CategoryWithModifedCount:2| and |other items|" """
354+
requires_list = requires
355+
356+
items_counts = world.get_item_counts()
357+
358+
functions = {}
359+
if requires_list == "":
360+
return True
361+
for item in re.findall(r'\{(\w+)\(([^)]*)\)\}', requires_list):
362+
#so this function doesn't try to get item from other functions, in theory.
363+
func_name = item[0]
364+
functions[func_name] = item[1]
365+
requires_list = requires_list.replace("{" + func_name + "(" + item[1] + ")}", "{" + func_name + "(temp)}")
366+
# parse user written statement into list of each item
367+
for item in re.findall(r'\|[^|]+\|', requires):
368+
itemScanned = OptOne(world, multiworld, state, player, item, items_counts)
369+
requires_list = requires_list.replace(item, itemScanned)
370+
371+
for function in functions:
372+
requires_list = requires_list.replace("{" + function + "(temp)}", "{" + func_name + "(" + functions[func_name] + ")}")
373+
return requires_list
374+
375+
def YamlEnabled(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, param: str) -> bool:
376+
"""Is a yaml option enabled?"""
377+
return is_option_enabled(multiworld, player, param)
378+
379+
def YamlDisabled(world: "ManualWorld", multiworld: MultiWorld, state: CollectionState, player: int, param: str) -> bool:
380+
"""Is a yaml option disabled?"""
381+
return not is_option_enabled(multiworld, player, param)

src/data/locations.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@
123123
{
124124
"name": "Beat the Game - Deadpool",
125125
"category": ["Unlocked Teams", "Right Side"],
126-
"requires": "|Deadpool|"
126+
"requires": "|Deadpool| or {YamlEnabled(free_deadpool)}"
127127
},
128128
{
129129
"name": "Beat the Game - Wolverine",

0 commit comments

Comments
 (0)