Files
dockipelago/docs/rule builder.md
Ian Robinson 286769a0f3 Core: Add rule builder (#5048)
* initial commit of rules engine

* implement most of the stuff

* add docs and fill out rest of the functionality

* add in explain functions

* dedupe items and add more docs

* pr feedback and optimization updates

* Self is not in typing on 3.10

* fix test

* Update docs/rule builder.md

Co-authored-by: BadMagic100 <dempsey.sean@outlook.com>

* pr feedback

* love it when CI gives me different results than local

* add composition with bitwise and and or

* strongly typed option filtering

* skip resolving location parent region

* update docs

* update typing and add decorator

* add string explains

* move simplify code to world

* add wrapper rule

* I may need to abandon the generic typing

* missing space for faris

* fix hashing for resolved rules

* thank u typing extensions ilu

* remove bad cacheable check

* add decorator to assign hash and rule name

* more type crimes...

* region access rules are now cached

* break compatibility so new features work

* update docs

* replace decorators with __init_subclass__

* ok now the frozen dataclass is automatic

* one more type fix for the road

* small fixes and caching tests

* play nicer with tests

* ok actually fix the tests

* add item_mapping for faris

* add more state helpers as rules

* fix has from list rules

* fix can reach location caching and add set completion condition

* fix can reach entrance caching

* implement HasGroup and HasGroupUnique

* add more tests and fix some bugs

* Add name arg to create_entrance

Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>

* fix json dumping option filters

* restructure and test serialization

* add prop to disable caching

* switch to __call__ and revert access_rule changes

* update docs and make edge cases match

* ruff has lured me into a false sense of security

* also unused

* fix disabling caching

* move filter function to filter class

* add more docs

* tests for explain functions

* Update docs/rule builder.md

Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>

* chore: Strip out uses of TYPE_CHECKING as much as possible

* chore: add empty webworld for test

* chore: optimize rule evaluations

* remove getattr from hot code paths

* testing new cache flags

* only clear cache for rules cached as false in collect

* update test for new behaviour

* do not have rules inherit from each other

* update docs on caching

* fix name of attribute

* make explain messages more colorful

* fix issue with combining rules with different options

* add convenience functions for filtering

* use an operator with higher precedence

* name conflicts less with optionfilter

* move simplify and instance caching code

* update docs

* kill resolve_rule

* kill true_rule and false_rule

* move helpers to base classes

* update docs

* I really should finish all of my

* fix test

* rename mixin

* fix typos

* refactor rule builder into folder for better imports

* update docs

* do not dupe collectionrule

* docs review feedback

* missed a file

* remove rule_caching_enabled from base World

* update docs on caching

* shuffle around some docs

* use option instead of option.value

* add in operator and more testing

* rm World = object

* test fixes

* move cache to logic mixin

* keep test rule builder world out of global registry

* todone

* call register_dependencies automatically

* move register deps call to call_single

* add filtered_resolution

* allow bool opts on filters

* fix serialization tests

* allow reverse operations

---------

Co-authored-by: BadMagic100 <dempsey.sean@outlook.com>
Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com>
2026-02-08 17:00:23 +01:00

22 KiB

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:

  • Rule classes that avoid all the common pitfalls
  • Logic optimization
  • Automatic result caching (opt-in)
  • Serialization/deserialization
  • Human-readable logic explanations for players

Overview

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.
  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.

# In your world's create_regions method
location = MyWorldLocation(...)
self.set_rule(location, Has("A Big Gun"))

The rule builder comes with a number of rules by default:

  • True_: Always returns true
  • False_: Always returns false
  • And: Checks that all child rules are true (also provided by & operator)
  • Or: Checks that at least one child rule is true (also provided by | operator)
  • Has: Checks that the player has the given item with the given count (default 1)
  • HasAll: Checks that the player has all given items
  • HasAny: Checks that the player has at least one of the given items
  • HasAllCounts: Checks that the player has all of the counts for the given items
  • HasAnyCount: Checks that the player has any of the counts for the given items
  • 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
  • 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:

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.

Assigning rules

When assigning the rule you must use the set_rule helper to correctly resolve and register the rule.

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.

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.

You can also set a rule for your world's completion condition:

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.

If you want a comparison that isn't equals, you can specify with the operator argument. The following operators are allowed:

  • eq: ==
  • ne: !=
  • gt: >
  • lt: <
  • ge: >=
  • le: <=
  • contains: in

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.

To check if the player can reach a switch, or if they've received the switch item if switches are randomized:

rule = (
    Has("Red switch", options=[OptionFilter(SwitchRando, 1)])
    | CanReachLocation("Red switch", options=[OptionFilter(SwitchRando, 0)])
)

To add an extra logic requirement on the easiest difficulty which is ignored for other difficulties:

rule = (
    # ...the rest of the logic
    & Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)], filtered_resolution=True)
)

If you would like to provide option filters when reusing or composing rules, you can use the Filtered helper rule:

common_rule = Has("A") | HasAny("B", "C")
...
rule = (
    Filtered(common_rule, options=[OptionFilter(Opt, 0)]),
    | Filtered(Has("X") | CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]),
)

You can also use the & and | operators to apply options to rules:

common_rule = Has("A")
easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)]
common_rule_only_on_easy = common_rule & easy_filter
common_rule_skipped_on_easy = common_rule | easy_filter

Enabling caching

The rule builder provides a CachedRuleBuilderWorld base class for your World class that enables caching on your rules.

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.

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.

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.

class MyWorld(CachedRuleBuilderWorld):
    item_mapping = {
        "Currency x10": "Currency",
        "Currency x50": "Currency",
        "Currency x100": "Currency",
        "Currency x500": "Currency",
    }

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

@dataclasses.dataclass()
class CanGoal(Rule["MyWorld"], game="My Game"):
    @override
    def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
        # caching_enabled only needs to be passed in when your world inherits from CachedRuleBuilderWorld
        return self.Resolved(world.required_mcguffins, player=world.player, caching_enabled=True)

    class Resolved(Rule.Resolved):
        goal: int

        @override
        def _evaluate(self, state: CollectionState) -> bool:
            return state.has("McGuffin", self.player, count=self.goal)

        @override
        def item_dependencies(self) -> dict[str, set[int]]:
            # this function is only required if you have caching enabled
            return {"McGuffin": {id(self)}}

        @override
        def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
            # this method can be overridden to display custom explanations
            return [
                {"type": "text", "text": "Goal with "},
                {"type": "color", "color": "green" if state and self(state) else "salmon", "text": str(self.goal)},
                {"type": "text", "text": " McGuffins"},
            ]

Your custom rule can also resolve to builtin rules instead of needing to define your own:

@dataclasses.dataclass()
class ComplicatedFilter(Rule["MyWorld"], game="My Game"):
    def _instantiate(self, world: "MyWorld") -> Rule.Resolved:
        if world.some_precalculated_bool:
            return Has("Item 1").resolve(world)
        if world.options.some_option:
            return CanReachRegion("Region 1").resolve(world)
        return False_().resolve(world)

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.

@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
    class Resolved(Rule.Resolved):
        item_name: str

        @override
        def item_dependencies(self) -> dict[str, set[int]]:
            return {self.item_name: {id(self)}}

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.

@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
    class Resolved(Rule.Resolved):
        region_name: str

        @override
        def region_dependencies(self) -> dict[str, set[int]]:
            return {self.region_name: {id(self)}}

The default CanReachLocation, CanReachRegion, and CanReachEntrance rules define this function already.

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.

@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
    class Resolved(Rule.Resolved):
        location_name: str

        @override
        def location_dependencies(self) -> dict[str, set[int]]:
            return {self.location_name: {id(self)}}

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.

@dataclasses.dataclass()
class MyRule(Rule["MyWorld"], game="My Game"):
    class Resolved(Rule.Resolved):
        entrance_name: str

        @override
        def entrance_dependencies(self) -> dict[str, set[int]]:
            return {self.entrance_name: {id(self)}}

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.

To implement a custom message with a custom rule, override the explain_json and/or explain_str method on your Resolved class:

class MyRule(Rule, game="My Game"):
    class Resolved(Rule.Resolved):
        @override
        def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]:
            has_item = state and state.has("growth spurt", self.player)
            color = "yellow"
            start = "You must be "
            if has_item:
                start = "You are "
                color = "green"
            elif state is not None:
                start = "You are not "
                color = "salmon"
            return [
                {"type": "text", "text": start},
                {"type": "color", "color": color, "text": "THIS"},
                {"type": "text", "text": " tall to beat the game"},
            ]

        @override
        def explain_str(self, state: CollectionState | None = None) -> str:
            if state is None:
                return str(self)
            if state.has("growth spurt", self.player):
                return "You ARE this tall and can beat the game"
            return "You are not THIS tall and cannot beat the game"

        @override
        def __str__(self) -> str:
            return "You must be THIS tall to beat the 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.

  • 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.
  • 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.

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

{
    "rule": "Has",
    "options": [],
    "args": {
        "item_name": "Some item",
        "count": 1,
    },
}

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:

{
    "rule": "And",
    "options": [],
    "children": [
        ...,  # each serialized rule
    ]
}

A full example is as follows:

rule = And(
    Has("a", options=[OptionFilter(ToggleOption, 0)]),
    Or(Has("b", count=2), CanReachRegion("c"), options=[OptionFilter(ToggleOption, 1)]),
)
assert rule.to_dict() == {
    "rule": "And",
    "options": [],
    "children": [
        {
            "rule": "Has",
            "options": [
                {
                    "option": "worlds.my_world.options.ToggleOption",
                    "value": 0,
                    "operator": "eq",
                },
            ],
            "args": {
                "item_name": "a",
                "count": 1,
            },
        },
        {
            "rule": "Or",
            "options": [
                {
                    "option": "worlds.my_world.options.ToggleOption",
                    "value": 1,
                    "operator": "eq",
                },
            ],
            "children": [
                {
                    "rule": "Has",
                    "options": [],
                    "args": {
                        "item_name": "b",
                        "count": 2,
                    },
                },
                {
                    "rule": "CanReachRegion",
                    "options": [],
                    "args": {
                        "region_name": "c",
                    },
                },
            ],
        },
    ],
}

Custom serialization

To define a different format for your custom rules, override the to_dict function:

class BasicLogicRule(Rule, game="My Game"):
    items = ("one", "two")

    def to_dict(self) -> dict[str, Any]:
        # Return whatever format works best for you
        return {
            "logic": "basic",
            "items": self.items,
        }

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:

class BasicLogicRule(Rule, game="My Game"):
    @classmethod
    def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self:
        items = data.get("items", ())
        return cls(*items)

APIs

This section is provided for reference, refer to the above sections for examples.

World API

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
  • 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

CachedRuleBuilderWorld Properties

The following property is only available when inheriting from CachedRuleBuilderWorld

  • item_mapping: dict[str, str]: A mapping of actual item name to logical item name

Rule API

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
  • __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
  • __str__(): A string describing this rule's logic without its evaluation, override to explain custom rules