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>
This commit is contained in:
Ian Robinson
2026-02-08 11:00:23 -05:00
committed by GitHub
parent 1dd91ec85b
commit 286769a0f3
10 changed files with 3956 additions and 38 deletions

View File

@@ -5,17 +5,18 @@ import logging
import pathlib
import sys
import time
from collections.abc import Callable, Iterable, Mapping
from random import Random
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
from typing import (Any, ClassVar, Dict, FrozenSet, List, Optional, Self, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState
from BaseClasses import CollectionState, Entrance
from rule_builder.rules import CustomRuleRegister, Rule
from Utils import Version
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
from BaseClasses import CollectionRule, Item, Location, MultiWorld, Region, Tutorial
from NetUtils import GamesPackage, MultiData
from settings import Group
@@ -177,7 +178,8 @@ def _timed_call(method: Callable[..., Any], *args: Any,
def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
method = getattr(multiworld.worlds[player], method_name)
world = multiworld.worlds[player]
method = getattr(world, method_name)
try:
ret = _timed_call(method, *args, multiworld=multiworld, player=player)
except Exception as e:
@@ -188,6 +190,10 @@ def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args:
logging.error(message)
raise e
else:
# Convenience for CachedRuleBuilderWorld users: Ensure that caching setup function is called
# Can be removed once dependency system is improved
if method_name == "set_rules" and hasattr(world, "register_rule_builder_dependencies"):
call_single(multiworld, "register_rule_builder_dependencies", player)
return ret
@@ -549,6 +555,10 @@ class World(metaclass=AutoWorldRegister):
return True
return False
def reached_region(self, state: "CollectionState", region: "Region") -> None:
"""Called when a region is newly reachable by the state."""
pass
# following methods should not need to be overridden.
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())
@@ -597,6 +607,64 @@ class World(metaclass=AutoWorldRegister):
res["checksum"] = data_package_checksum(res)
return res
@classmethod
def get_rule_cls(cls, name: str) -> type[Rule[Self]]:
"""Returns the world-registered or default rule with the given name"""
return CustomRuleRegister.get_rule_cls(cls.game, name)
@classmethod
def rule_from_dict(cls, data: Mapping[str, Any]) -> Rule[Self]:
"""Create a rule instance from a serialized dict representation"""
name = data.get("rule", "")
rule_class = cls.get_rule_cls(name)
return rule_class.from_dict(data, cls)
def set_rule(self, spot: Location | Entrance, rule: CollectionRule | Rule[Any]) -> None:
"""Sets an access rule for a location or entrance"""
if isinstance(rule, Rule):
rule = rule.resolve(self)
self.register_rule_dependencies(rule)
if isinstance(spot, Entrance):
self._register_rule_indirects(rule, spot)
spot.access_rule = rule
def set_completion_rule(self, rule: CollectionRule | Rule[Any]) -> None:
"""Set the completion rule for this world"""
if isinstance(rule, Rule):
rule = rule.resolve(self)
self.register_rule_dependencies(rule)
self.multiworld.completion_condition[self.player] = rule
def create_entrance(
self,
from_region: Region,
to_region: Region,
rule: CollectionRule | Rule[Any] | None = None,
name: str | None = None,
force_creation: bool = False,
) -> Entrance | None:
"""Try to create an entrance between regions with the given rule,
skipping it if the rule resolves to False (unless force_creation is True)"""
if rule is not None and isinstance(rule, Rule):
rule = rule.resolve(self)
if rule.always_false and not force_creation:
return None
self.register_rule_dependencies(rule)
entrance = from_region.connect(to_region, name, rule=rule)
if rule and isinstance(rule, Rule.Resolved):
self._register_rule_indirects(rule, entrance)
return entrance
def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None:
"""Hook for registering dependencies when a rule is assigned for this world"""
pass
def _register_rule_indirects(self, resolved_rule: Rule.Resolved, entrance: Entrance) -> None:
if self.explicit_indirect_conditions:
for indirect_region in resolved_rule.region_dependencies().keys():
self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance)
# any methods attached to this can be used as part of CollectionState,
# please use a prefix as all of them get clobbered together

View File

@@ -2,16 +2,10 @@ import collections
import logging
import typing
from BaseClasses import LocationProgressType, MultiWorld, Location, Region, Entrance
from BaseClasses import (CollectionRule, CollectionState, Entrance, Item, Location,
LocationProgressType, MultiWorld, Region)
if typing.TYPE_CHECKING:
import BaseClasses
CollectionRule = typing.Callable[[BaseClasses.CollectionState], bool]
ItemRule = typing.Callable[[BaseClasses.Item], bool]
else:
CollectionRule = typing.Callable[[object], bool]
ItemRule = typing.Callable[[object], bool]
ItemRule = typing.Callable[[Item], bool]
def locality_needed(multiworld: MultiWorld) -> bool:
@@ -96,11 +90,11 @@ def exclusion_rules(multiworld: MultiWorld, player: int, exclude_locations: typi
logging.warning(f"Unable to exclude location {loc_name} in player {player}'s world.")
def set_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule):
def set_rule(spot: typing.Union[Location, Entrance], rule: CollectionRule):
spot.access_rule = rule
def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule, combine="and"):
def add_rule(spot: typing.Union[Location, Entrance], rule: CollectionRule, combine="and"):
old_rule = spot.access_rule
# empty rule, replace instead of add
if old_rule is Location.access_rule or old_rule is Entrance.access_rule:
@@ -112,7 +106,7 @@ def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"],
spot.access_rule = lambda state: rule(state) or old_rule(state)
def forbid_item(location: "BaseClasses.Location", item: str, player: int):
def forbid_item(location: Location, item: str, player: int):
old_rule = location.item_rule
# empty rule
if old_rule is Location.item_rule:
@@ -121,18 +115,18 @@ def forbid_item(location: "BaseClasses.Location", item: str, player: int):
location.item_rule = lambda i: (i.name != item or i.player != player) and old_rule(i)
def forbid_items_for_player(location: "BaseClasses.Location", items: typing.Set[str], player: int):
def forbid_items_for_player(location: Location, items: typing.Set[str], player: int):
old_rule = location.item_rule
location.item_rule = lambda i: (i.player != player or i.name not in items) and old_rule(i)
def forbid_items(location: "BaseClasses.Location", items: typing.Set[str]):
def forbid_items(location: Location, items: typing.Set[str]):
"""unused, but kept as a debugging tool."""
old_rule = location.item_rule
location.item_rule = lambda i: i.name not in items and old_rule(i)
def add_item_rule(location: "BaseClasses.Location", rule: ItemRule, combine: str = "and"):
def add_item_rule(location: Location, rule: ItemRule, combine: str = "and"):
old_rule = location.item_rule
# empty rule, replace instead of add
if old_rule is Location.item_rule:
@@ -144,7 +138,7 @@ def add_item_rule(location: "BaseClasses.Location", rule: ItemRule, combine: str
location.item_rule = lambda item: rule(item) or old_rule(item)
def item_name_in_location_names(state: "BaseClasses.CollectionState", item: str, player: int,
def item_name_in_location_names(state: CollectionState, item: str, player: int,
location_name_player_pairs: typing.Sequence[typing.Tuple[str, int]]) -> bool:
for location in location_name_player_pairs:
if location_item_name(state, location[0], location[1]) == (item, player):
@@ -153,14 +147,14 @@ def item_name_in_location_names(state: "BaseClasses.CollectionState", item: str,
def item_name_in_locations(item: str, player: int,
locations: typing.Sequence["BaseClasses.Location"]) -> bool:
locations: typing.Sequence[Location]) -> bool:
for location in locations:
if location.item and location.item.name == item and location.item.player == player:
return True
return False
def location_item_name(state: "BaseClasses.CollectionState", location: str, player: int) -> \
def location_item_name(state: CollectionState, location: str, player: int) -> \
typing.Optional[typing.Tuple[str, int]]:
location = state.multiworld.get_location(location, player)
if location.item is None: