Docs: Make RB OptionFilter docs clearer (#6028)

* add more docs for option filters

* reword the whole things

* style guide
This commit is contained in:
Ian Robinson
2026-04-29 15:14:34 -04:00
committed by GitHub
parent 73856f63c8
commit a48bf0195f

View File

@@ -1,6 +1,7 @@
# Rule Builder
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface to define rules and the following advantages:
This document describes the API provided for the rule builder. Using this API provides you with with a simple interface
to define rules and the following advantages:
- Rule classes that avoid all the common pitfalls
- Logic optimization
@@ -12,13 +13,21 @@ This document describes the API provided for the rule builder. Using this API pr
The rule builder consists of 3 main parts:
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic. They can be combined and take into account your world's options. There are a number of default rules listed below, and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance they must be resolved.
1. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly creating these but they'll be created when assigning rules to locations or entrances. These are what power the human-readable logic explanations.
1. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
1. The rules, which are classes that inherit from `rule_builder.rules.Rule`. These are what you write for your logic.
They can be combined and take into account your world's options. There are a number of default rules listed below,
and you can create as many custom rules for your world as needed. When assigning the rules to a location or entrance
they must be resolved.
2. Resolved rules, which are classes that inherit from `rule_builder.rules.Rule.Resolved`. These are the optimized rules
specific to one player that are set as a location or entrance's access rule. You generally shouldn't be directly
creating these but they'll be created when assigning rules to locations or entrances. These are what power the
human-readable logic explanations.
3. The optional rule builder world subclass `CachedRuleBuilderWorld`, which is a class your world can inherit from
instead of `World`. It adds a caching system to the rules that will lazy evaluate and cache the result.
## Usage
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
For the most part the only difference in usage is instead of writing lambdas for your logic, you write static Rule
objects. You then must use `world.set_rule` to assign the rule to a location or entrance.
```python
# In your world's create_regions method
@@ -40,18 +49,22 @@ The rule builder comes with a number of rules by default:
- `HasFromList`: Checks that the player has some number of given items
- `HasFromListUnique`: Checks that the player has some number of given items, ignoring duplicates of the same item
- `HasGroup`: Checks that the player has some number of items from a given item group
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the same item
- `HasGroupUnique`: Checks that the player has some number of items from a given item group, ignoring duplicates of the
same item
- `CanReachLocation`: Checks that the player can logically reach the given location
- `CanReachRegion`: Checks that the player can logically reach the given region
- `CanReachEntrance`: Checks that the player can logically reach the given entrance
You can combine these rules together to describe the logic required for something. For example, to check if a player either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
You can combine these rules together to describe the logic required for something. For example, to check if a player
either has `Movement ability` or they have both `Key 1` and `Key 2`, you can do:
```python
rule = Has("Movement ability") | HasAll("Key 1", "Key 2")
```
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check if a rule is defined you must use `if rule is not None`.
> ⚠️ Composing rules with the `and` and `or` keywords will not work. You must use the bitwise `&` and `|` operators. In
> order to catch mistakes, the rule builder will not let you do boolean operations. As a consequence, in order to check
> if a rule is defined you must use `if rule is not None`.
### Assigning rules
@@ -61,13 +74,16 @@ When assigning the rule you must use the `set_rule` helper to correctly resolve
self.set_rule(location_or_entrance, rule)
```
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify `force_creation=True` if you would like to create the entrance even if the rule is `False`.
There is also a `create_entrance` helper that will resolve the rule, check if it's `False`, and if not create the
entrance and set the rule. This allows you to skip creating entrances that will never be valid. You can also specify
`force_creation=True` if you would like to create the entrance even if the rule is `False`.
```python
self.create_entrance(from_region, to_region, rule)
```
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
> ⚠️ If you use a `CanReachLocation` rule on an entrance, you will either have to create the locations first, or specify
> the location's parent region name with the `parent_region_name` argument of `CanReachLocation`.
You can also set a rule for your world's completion condition:
@@ -77,21 +93,42 @@ self.set_completion_rule(rule)
### Restricting options
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an iterable of `OptionFilter` instances. Rules that pass the options check will be resolved as normal, and those that fail will be resolved as `False`.
Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is an
iterable of `OptionFilter` instances. When resolved, if no filters are provided or all of them pass then the rule will
resolve as normal. Otherwise, the rule will be replaced with `True` or `False` depending on what `filtered_resolution`
is set to, which defaults to `False`.
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed:
```python
rule1 = Has(
"Fast Travel Spell",
options=[OptionFilter(RandoFastTravel, RandoFastTravel.option_true)],
)
rule2 = Has(
"Starting Party Member",
options=[OptionFilter(RandoParty, 1)], # option attributes are suggested but any value works
filtered_resolution=True,
)
```
- `eq`: `==`
- `ne`: `!=`
- `gt`: `>`
- `lt`: `<`
- `ge`: `>=`
- `le`: `<=`
- `contains`: `in`
If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are
allowed:
By default rules that are excluded by their options will default to `False`. If you want to default to `True` instead, you can specify `filtered_resolution=True` on your rule.
- `eq`: `option_value == filter_value`
- `ne`: `option_value != filter_value`
- `gt`: `option_value > filter_value`
- `lt`: `option_value < filter_value`
- `ge`: `option_value >= filter_value`
- `le`: `option_value <= filter_value`
- `in`: `option_value in filter_value`
- `contains`: `filter_value in option_value` (note reversed operands)
To check if the player can reach a switch, or if they've received the switch item if switches are randomized:
```python
rule1 = Has("Movement Ability", options=[OptionFilter(SkipsLevel, SkipsLevel.option_hard, operator="lt")])
rule2 = Has("Item", options=[OptionFilter(ChoiceOption, [1, 5], operator="in")])
```
To check if the player has received the switch item if switches are randomized, or if they can reach the switch when not
randomized:
```python
rule = (
@@ -120,7 +157,7 @@ rule = (
)
```
You can also use the & and | operators to apply options to rules:
For convenience, you can also use the `&` and `|` operators to apply options to rules:
```python
common_rule = Has("A")
@@ -129,14 +166,22 @@ common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter
```
Combining the above, you can easily bypass a requirement based on option choices:
```python
rule = Has("Some Upgrade") | OptionFilter(CombatDifficulty, CombatDifficulty.option_medium, operator="ge")
```
### Field resolvers
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a `FieldResolver` to define how to populate that field when the rule is being resolved.
When creating rules you may sometimes need to set a field to a value that depends on the world instance. You can use a
`FieldResolver` to define how to populate that field when the rule is being resolved.
There are two build-in field resolvers:
- `FromOption`: Resolves to the value of the given option
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get a nested attribute or dict item
- `FromWorldAttr`: Resolves to the value of the given world instance attribute, can specify a dotted path `a.b.c` to get
a nested attribute or dict item
```python
world.options.mcguffin_count = 5
@@ -148,7 +193,8 @@ rule = (
# Results in Has("A", count=5) | HasGroup("Important items", count=99)
```
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and implements a `resolve` function:
You can define your own resolvers by creating a class that inherits from `FieldResolver`, provides your game name, and
implements a `resolve` function:
```python
@dataclasses.dataclass(frozen=True)
@@ -163,24 +209,30 @@ class FromCustomResolution(FieldResolver, game="MyGame"):
rule = Has("Combat Level", count=FromCustomResolution("combat"))
```
If you want to support rule serialization and your resolver contains non-serializable properties you may need to override `to_dict` or `from_dict`.
If you want to support rule serialization and your resolver contains non-serializable properties you may need to
override `to_dict` or `from_dict`.
## Enabling caching
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules.
The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your
rules.
```python
class MyWorld(CachedRuleBuilderWorld):
game = "My Game"
```
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
If your world's logic is very simple and you don't have many nested rules, the caching system may have more overhead
cost than time it saves. You'll have to benchmark your own world to see if it should be enabled or not.
### Item name mapping
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps actual item names to real item names so the cache system knows what to invalidate.
If you have multiple real items that map to a single logic item, add a `item_mapping` class dict to your world that maps
actual item names to real item names so the cache system knows what to invalidate.
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`.
For example, if you have multiple `Currency x<num>` items on locations, but your rules only check a singular logical
`Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical
`Currency`.
```python
class MyWorld(CachedRuleBuilderWorld):
@@ -194,9 +246,13 @@ class MyWorld(CachedRuleBuilderWorld):
## Defining custom rules
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules. You must provide
the game name as an argument to the class. It's recommended to use the `@dataclass` decorator to reduce boilerplate, and
to also provide your world as a type argument to add correct type checking to the `_instantiate` method.
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more dependencies functions as outlined below.
You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically
be converted into a frozen `dataclass`. If your world has caching enabled you may need to define one or more
dependencies functions as outlined below.
To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement:
@@ -245,7 +301,10 @@ class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
### Item dependencies
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this function even when caching is disabled as more things may use it in the future.
If your world inherits from `CachedRuleBuilderWorld` and there are items that when collected will affect the result of
your rule evaluation, it must define an `item_dependencies` function that returns a mapping of the item name to the id
of your rule. These dependencies will be combined to inform the caching system. It may be worthwhile to define this
function even when caching is disabled as more things may use it in the future.
```python
@dataclasses.dataclass()
@@ -262,7 +321,10 @@ All of the default `Has*` rules define this function already.
### Region dependencies
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
If your custom rule references other regions, it must define a `region_dependencies` function that returns a mapping of
region names to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
```python
@dataclasses.dataclass()
@@ -279,7 +341,10 @@ The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules d
### Location dependencies
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
If your custom rule references other locations, it must define a `location_dependencies` function that returns a mapping
of the location name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
```python
@dataclasses.dataclass()
@@ -296,7 +361,10 @@ The default `CanReachLocation` rule defines this function already.
### Entrance dependencies
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the caching system if applicable.
If your custom rule references other entrances, it must define a `entrance_dependencies` function that returns a mapping
of the entrance name to the id of your rule regardless of if your world inherits from `CachedRuleBuilderWorld`. These
dependencies will be combined to register indirect connections when you set this rule on an entrance and inform the
caching system if applicable.
```python
@dataclasses.dataclass()
@@ -313,9 +381,13 @@ The default `CanReachEntrance` rule defines this function already.
### Rule explanations
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former optionally
accepts a `CollectionState` and returns a list of `JSONMessagePart` appropriate for `print_json` in a client. It will
display a human-readable message that explains what the rule requires. The latter is similar but returns a string. It is
useful when debugging. There is also a `__str__` method defined to check what a rule is without a state.
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your `Resolved` class:
To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your
`Resolved` class:
```python
class MyRule(Rule, game="My Game"):
@@ -352,22 +424,35 @@ class MyRule(Rule, game="My Game"):
### Cache control
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two class attributes on the `Resolved` class you can override to change this behavior.
By default your custom rule will work through the cache system as any other rule if caching is enabled. There are two
class attributes on the `Resolved` class you can override to change this behavior.
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your rule should be marked as stale.
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the overhead of the caching system will slow it down.
- `force_recalculate`: Setting this to `True` will cause your custom rule to skip going through the caching system and
always recalculate when being evaluated. When a rule with this flag enabled is composed with `And` or `Or` it will
cause any parent rules to always force recalculate as well. Use this flag when it's difficult to determine when your
rule should be marked as stale.
- `skip_cache`: Setting this to `True` will also cause your custom rule to skip going through the caching system when
being evaluated. However, it will **not** affect any other rules when composed with `And` or `Or`, so it must still
define its `*_dependencies` functions as required. Use this flag when the evaluation of this rule is trivial and the
overhead of the caching system will slow it down.
### Caveats
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if your world has opted into caching.
- Ensure you are passing `caching_enabled=True` in your `_instantiate` function when creating resolved rule instances if
your world has opted into caching.
- Resolved rules are forced to be frozen dataclasses. They and all their attributes must be immutable and hashable.
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved` instances directly.
- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved`
instances directly.
## Serialization
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
The rule builder is intended to be written first in Python for optimization and type safety. To facilitate exporting the
rules to a client or tracker, rules have a `to_dict` method that returns a JSON-compatible dict. Since the location and
entrance logic structure varies greatly from world to world, the actual JSON dumping is left up to the world dev.
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule would look like:
The dict contains a `rule` key with the name of the rule, an `options` key with the rule's list of option filters, and
an `args` key that contains any other arguments the individual rule has. For example, this is what a simple `Has` rule
would look like:
```python
{
@@ -380,7 +465,8 @@ The dict contains a `rule` key with the name of the rule, an `options` key with
}
```
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in the same serializable format:
For `And` and `Or` rules, instead of an `args` key, they have a `children` key containing a list of their child rules in
the same serializable format:
```python
{
@@ -464,7 +550,8 @@ class BasicLogicRule(Rule, game="My Game"):
}
```
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it correctly:
If your logic has been done in custom JSON first, you can define a `from_dict` class method on your rules to parse it
correctly:
```python
class BasicLogicRule(Rule, game="My Game"):
@@ -485,10 +572,14 @@ These are properties and helpers that are available to you in your world.
#### Methods
- `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies, gets called automatically after set_rules
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance
- `register_rule_builder_dependencies()`: Register all rules that depend on location or entrance access with the
inherited dependencies, gets called automatically after set_rules
- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given
location or entrance
- `set_completion_rule(rule: Rule)`: Sets the completion condition for this world
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` unless force_creation is `True`
- `create_entrance(from_region: Region, to_region: Region, rule: Rule | None, name: str | None = None, force_creation: bool = False)`:
Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates
to `False_()` unless force_creation is `True`
#### CachedRuleBuilderWorld Properties
@@ -501,18 +592,27 @@ The following property is only available when inheriting from `CachedRuleBuilder
These are properties and helpers that you can use or override for custom rules.
- `_instantiate(world: World)`: Create a new resolved rule instance, override for custom rules as required
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's serialization
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if you've overridden `to_dict`
- `to_dict()`: Create a JSON-compatible dict representation of this rule, override if you want to customize your rule's
serialization
- `from_dict(data, world_cls: type[World])`: Return a new rule instance from a deserialized representation, override if
you've overridden `to_dict`
- `__str__()`: Basic string representation of a rule, useful for debugging
#### Resolved rule API
- `player: int`: The slot this rule is resolved for
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for this rule
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item collection
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching regions
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on reaching locations
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on reaching entrances
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
- `_evaluate(state: CollectionState)`: Evaluate this rule against the given state, override this to define the logic for
this rule
- `item_dependencies()`: A mapping of item name to set of ids, override this if your custom rule depends on item
collection
- `region_dependencies()`: A mapping of region name to set of ids, override this if your custom rule depends on reaching
regions
- `location_dependencies()`: A mapping of location name to set of ids, override this if your custom rule depends on
reaching locations
- `entrance_dependencies()`: A mapping of entrance name to set of ids, override this if your custom rule depends on
reaching entrances
- `explain_json(state: CollectionState | None = None)`: Return a list of printJSON messages describing this rule's logic
(and if state is defined its evaluation) in a human readable way, override to explain custom rules
- `explain_str(state: CollectionState | None = None)`: Return a string describing this rule's logic (and if state is
defined its evaluation) in a human readable way, override to explain custom rules, more useful for debugging
- `__str__()`: A string describing this rule's logic without its evaluation, override to explain custom rules