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
+12 -18
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: