forked from mirror/Archipelago
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user