From 0018d2f243b7b54f4654a4667ac8c8b3174d6bb2 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 20 May 2025 23:07:32 -0400 Subject: [PATCH 001/135] initial commit of rules engine --- rules.py | 293 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 rules.py diff --git a/rules.py b/rules.py new file mode 100644 index 000000000000..f204d1b4aa10 --- /dev/null +++ b/rules.py @@ -0,0 +1,293 @@ +import dataclasses +import itertools +import operator +from typing import TYPE_CHECKING, Any, ClassVar + +if TYPE_CHECKING: + from BaseClasses import CollectionState + from Options import CommonOptions, Option + from worlds.AutoWorld import World + +OPERATORS = { + "eq": operator.eq, + "ne": operator.ne, + "gt": operator.gt, + "lt": operator.lt, + "ge": operator.ge, + "le": operator.le, + "contains": operator.contains, +} + + +@dataclasses.dataclass() +class Rule: + options: dict[str, Any] = dataclasses.field(default_factory=dict, kw_only=True) + """A mapping of option_name to value""" + + def _passes_options(self, options: "CommonOptions") -> bool: + for key, value in self.options: + parts = key.split("__", maxsplit=1) + option_name = parts[0] + operator = parts[1] if len(parts) > 1 else "eq" + opt: Option = getattr(options, option_name) + if not OPERATORS[operator](opt.value, value): + return False + return True + + def _instantiate(self, world: "World") -> "Instance": + return self.Instance(player=world.player) + + def resolve(self, world: "World") -> "Instance": + if not self._passes_options(world.options): + return False_.Instance(player=world.player) + + instance = self._instantiate(world) + rule_hash = hash(instance) + if rule_hash not in world.rule_cache: + world.rule_cache[rule_hash] = instance + return world.rule_cache[rule_hash] + + @dataclasses.dataclass(kw_only=True, frozen=True) + class Instance: + player: int + cacheable: bool = dataclasses.field(repr=False, default=True) + + always_true: ClassVar = False + always_false: ClassVar = False + + def __hash__(self) -> int: + return hash((self.__class__.__name__, *[getattr(self, f.name) for f in dataclasses.fields(self)])) + + def _evaluate(self, state: "CollectionState") -> bool: ... + + def evaluate(self, state: "CollectionState") -> bool: + result = self._evaluate(state) + if self.cacheable: + state._astalon_rule_results[self.player][id(self)] = result # type: ignore + return result + + def test(self, state: "CollectionState") -> bool: + cached_result = None + if self.cacheable: + cached_result = state._astalon_rule_results[self.player].get(id(self)) # type: ignore + if cached_result is not None: + return cached_result + return self.evaluate(state) + + def item_dependencies(self) -> dict[str, set[int]]: + return {} + + def indirect_regions(self) -> tuple[str, ...]: + return () + + +@dataclasses.dataclass() +class True_(Rule): + @dataclasses.dataclass(frozen=True) + class Instance(Rule.Instance): + cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + always_true = True + + def _evaluate(self, state: "CollectionState") -> bool: + return True + + +@dataclasses.dataclass() +class False_(Rule): + @dataclasses.dataclass(frozen=True) + class Instance(Rule.Instance): + cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + always_false = True + + def _evaluate(self, state: "CollectionState") -> bool: + return False + + +@dataclasses.dataclass(init=False) +class NestedRule(Rule): + children: "tuple[Rule, ...]" + + def __init__(self, *children: "Rule", options: dict[str, Any] | None = None) -> None: + super().__init__(options=options or {}) + self.children = children + + def _instantiate(self, world: "World") -> "Instance": + children = [c.resolve(world) for c in self.children] + return self.Instance(tuple(children), player=world.player).simplify() # type: ignore + + @dataclasses.dataclass(frozen=True) + class Instance(Rule.Instance): + children: "tuple[Rule.Instance, ...]" + + def item_dependencies(self) -> dict[str, set[int]]: + combined_deps: dict[str, set[int]] = {} + for child in self.children: + for item_name, rules in child.item_dependencies().items(): + if item_name in combined_deps: + combined_deps[item_name] |= rules + else: + combined_deps[item_name] = {id(self), *rules} + return combined_deps + + def indirect_regions(self) -> tuple[str, ...]: + return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) + + def simplify(self) -> "Rule.Instance": + return self + + +@dataclasses.dataclass(init=False) +class And(NestedRule): + @dataclasses.dataclass(frozen=True) + class Instance(NestedRule.Instance): + def _evaluate(self, state: "CollectionState") -> bool: + for rule in self.children: + if not rule.test(state): + return False + return True + + def simplify(self) -> "Rule.Instance": + children_to_process = list(self.children) + clauses: list[Rule.Instance] = [] + items: list[str] = [] + true_rule: Rule.Instance | None = None + + while children_to_process: + child = children_to_process.pop(0) + if child.always_false: + # false always wins + return child + if child.always_true: + # dedupe trues + true_rule = child + continue + if isinstance(child, And.Instance): + children_to_process.extend(child.children) + continue + + if isinstance(child, Has.Instance) and child.count == 1: + items.append(child.item) + elif isinstance(child, HasAll.Instance): + items.extend(child.items) + else: + clauses.append(child) + + if not clauses and not items: + return true_rule or False_.Instance(player=self.player) + if items: + if len(items) == 1: + item_rule = Has.Instance(items[0], player=self.player) + else: + item_rule = HasAll.Instance(tuple(items), player=self.player) + if not clauses: + return item_rule + clauses.append(item_rule) + + if len(clauses) == 1: + return clauses[0] + return And.Instance( + tuple(clauses), + player=self.player, + cacheable=self.cacheable and all(c.cacheable for c in clauses), + ) + + +@dataclasses.dataclass(init=False) +class Or(NestedRule): + @dataclasses.dataclass(frozen=True) + class Instance(NestedRule.Instance): + def _evaluate(self, state: "CollectionState") -> bool: + for rule in self.children: + if rule.test(state): + return True + return False + + def simplify(self) -> "Rule.Instance": + children_to_process = list(self.children) + clauses: list[Rule.Instance] = [] + items: list[str] = [] + + while children_to_process: + child = children_to_process.pop(0) + if child.always_true: + # true always wins + return child + if child.always_false: + # falses can be ignored + continue + if isinstance(child, Or.Instance): + children_to_process.extend(child.children) + continue + + if isinstance(child, Has.Instance) and child.count == 1: + items.append(child.item) + elif isinstance(child, HasAny.Instance): + items.extend(child.items) + else: + clauses.append(child) + + if not clauses and not items: + return False_.Instance(player=self.player) + if items: + if len(items) == 1: + item_rule = Has.Instance(items[0], player=self.player) + else: + item_rule = HasAny.Instance(tuple(items), player=self.player) + if not clauses: + return item_rule + clauses.append(item_rule) + + if len(clauses) == 1: + return clauses[0] + return Or.Instance( + tuple(clauses), + player=self.player, + cacheable=self.cacheable and all(c.cacheable for c in clauses), + ) + + +@dataclasses.dataclass() +class Has(Rule): + item: str + count: int = 1 + + @dataclasses.dataclass(frozen=True) + class Instance(Rule.Instance): + item: str + count: int = 1 + + def _evaluate(self, state: "CollectionState") -> bool: + return state.has(self.item, self.player, count=self.count) + + def item_dependencies(self) -> dict[str, set[int]]: + return {self.item: {id(self)}} + + +@dataclasses.dataclass() +class HasAll(Rule): + items: tuple[str, ...] + + @dataclasses.dataclass(frozen=True) + class Instance(Rule.Instance): + items: tuple[str, ...] + + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_all(self.items, self.player) + + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.items} + + +@dataclasses.dataclass() +class HasAny(Rule): + items: tuple[str, ...] + + @dataclasses.dataclass(frozen=True) + class Instance(Rule.Instance): + items: tuple[str, ...] + + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_any(self.items, self.player) + + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.items} From 2d4ef18edc48dac0dd716eb18bfe3b7099cd0ef7 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 22 May 2025 23:55:26 -0400 Subject: [PATCH 002/135] implement most of the stuff --- BaseClasses.py | 4 + rules.py | 223 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 181 insertions(+), 46 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index f480cbbda3de..065ef110ea53 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -732,6 +732,7 @@ class CollectionState(): locations_checked: Set[Location] stale: Dict[int, bool] allow_partial_entrances: bool + rule_cache: dict[int, dict[int, bool]] additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] @@ -740,6 +741,7 @@ def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()} + self.rule_cache = {player: {} for player in parent.get_all_ids()} self.advancements = set() self.path = {} self.locations_checked = set() @@ -825,6 +827,8 @@ def copy(self) -> CollectionState: self.reachable_regions.items()} ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in self.blocked_connections.items()} + ret.rule_cache = {player: player_cache.copy() for player, player_cache in + self.rule_cache.items()} ret.advancements = self.advancements.copy() ret.path = self.path.copy() ret.locations_checked = self.locations_checked.copy() diff --git a/rules.py b/rules.py index f204d1b4aa10..c1e31cef5571 100644 --- a/rules.py +++ b/rules.py @@ -1,12 +1,14 @@ import dataclasses import itertools import operator +from collections import defaultdict from typing import TYPE_CHECKING, Any, ClassVar +from worlds.AutoWorld import World + if TYPE_CHECKING: - from BaseClasses import CollectionState + from BaseClasses import CollectionState, Entrance, Item, MultiWorld from Options import CommonOptions, Option - from worlds.AutoWorld import World OPERATORS = { "eq": operator.eq, @@ -21,70 +23,98 @@ @dataclasses.dataclass() class Rule: + """Base class for a static rule used to generate a""" + options: dict[str, Any] = dataclasses.field(default_factory=dict, kw_only=True) - """A mapping of option_name to value""" + """A mapping of option_name to value to restrict what options are required for this rule to be active. + An operator can be specified with a double underscore and the operator after the option name, eg `name__le` + """ def _passes_options(self, options: "CommonOptions") -> bool: + """Tests if the given world options pass the requirements for this rule""" for key, value in self.options: parts = key.split("__", maxsplit=1) option_name = parts[0] operator = parts[1] if len(parts) > 1 else "eq" - opt: Option = getattr(options, option_name) + opt: Option[Any] = getattr(options, option_name) if not OPERATORS[operator](opt.value, value): return False return True - def _instantiate(self, world: "World") -> "Instance": - return self.Instance(player=world.player) + def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + """Create a new resolved rule for this world""" + return self.Resolved(player=world.player) - def resolve(self, world: "World") -> "Instance": + def resolve(self, world: "RuleWorldMixin") -> "Resolved": + """Resolve a rule with the given world""" if not self._passes_options(world.options): - return False_.Instance(player=world.player) + return False_.Resolved(player=world.player) instance = self._instantiate(world) rule_hash = hash(instance) - if rule_hash not in world.rule_cache: - world.rule_cache[rule_hash] = instance - return world.rule_cache[rule_hash] + if rule_hash not in world.rule_ids: + world.rule_ids[rule_hash] = instance + return world.rule_ids[rule_hash] + + def to_json(self) -> dict[str, Any]: + """Returns a JSON-serializable definition of this rule""" + return { + "type": self.__class__.__name__, + "args": {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)}, + } @dataclasses.dataclass(kw_only=True, frozen=True) - class Instance: + class Resolved: + """A resolved rule for a given world that can be used as an access rule""" + player: int + """The player this rule is for""" + cacheable: bool = dataclasses.field(repr=False, default=True) + """If this rule should be cached in the state""" always_true: ClassVar = False + """Whether this rule always evaluates to True, used to short-circuit logic""" + always_false: ClassVar = False + """Whether this rule always evaluates to True, used to short-circuit logic""" def __hash__(self) -> int: return hash((self.__class__.__name__, *[getattr(self, f.name) for f in dataclasses.fields(self)])) - def _evaluate(self, state: "CollectionState") -> bool: ... + def _evaluate(self, state: "CollectionState") -> bool: + """Calculate this rule's result with the given state""" + ... def evaluate(self, state: "CollectionState") -> bool: + """Evaluate this rule's result with the given state and cache the result if applicable""" result = self._evaluate(state) if self.cacheable: - state._astalon_rule_results[self.player][id(self)] = result # type: ignore + state.rule_cache[self.player][id(self)] = result return result def test(self, state: "CollectionState") -> bool: + """Evaluate this rule's result with the given state, using the cached value if possible""" cached_result = None if self.cacheable: - cached_result = state._astalon_rule_results[self.player].get(id(self)) # type: ignore + cached_result = state.rule_cache[self.player].get(id(self)) if cached_result is not None: return cached_result return self.evaluate(state) def item_dependencies(self) -> dict[str, set[int]]: + """Returns a mapping of item name to set of object ids to be used for cache invalidation""" return {} def indirect_regions(self) -> tuple[str, ...]: + """Returns a tuple of region names this rule is indirectly connected to""" return () @dataclasses.dataclass() class True_(Rule): @dataclasses.dataclass(frozen=True) - class Instance(Rule.Instance): + class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_true = True @@ -95,7 +125,7 @@ def _evaluate(self, state: "CollectionState") -> bool: @dataclasses.dataclass() class False_(Rule): @dataclasses.dataclass(frozen=True) - class Instance(Rule.Instance): + class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_false = True @@ -111,13 +141,13 @@ def __init__(self, *children: "Rule", options: dict[str, Any] | None = None) -> super().__init__(options=options or {}) self.children = children - def _instantiate(self, world: "World") -> "Instance": + def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": children = [c.resolve(world) for c in self.children] - return self.Instance(tuple(children), player=world.player).simplify() # type: ignore + return self.Resolved(tuple(children), player=world.player).simplify() @dataclasses.dataclass(frozen=True) - class Instance(Rule.Instance): - children: "tuple[Rule.Instance, ...]" + class Resolved(Rule.Resolved): + children: "tuple[Rule.Resolved, ...]" def item_dependencies(self) -> dict[str, set[int]]: combined_deps: dict[str, set[int]] = {} @@ -132,25 +162,25 @@ def item_dependencies(self) -> dict[str, set[int]]: def indirect_regions(self) -> tuple[str, ...]: return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) - def simplify(self) -> "Rule.Instance": + def simplify(self) -> "Rule.Resolved": return self @dataclasses.dataclass(init=False) class And(NestedRule): @dataclasses.dataclass(frozen=True) - class Instance(NestedRule.Instance): + class Resolved(NestedRule.Resolved): def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: if not rule.test(state): return False return True - def simplify(self) -> "Rule.Instance": + def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) - clauses: list[Rule.Instance] = [] + clauses: list[Rule.Resolved] = [] items: list[str] = [] - true_rule: Rule.Instance | None = None + true_rule: Rule.Resolved | None = None while children_to_process: child = children_to_process.pop(0) @@ -161,31 +191,31 @@ def simplify(self) -> "Rule.Instance": # dedupe trues true_rule = child continue - if isinstance(child, And.Instance): + if isinstance(child, And.Resolved): children_to_process.extend(child.children) continue - if isinstance(child, Has.Instance) and child.count == 1: + if isinstance(child, Has.Resolved) and child.count == 1: items.append(child.item) - elif isinstance(child, HasAll.Instance): + elif isinstance(child, HasAll.Resolved): items.extend(child.items) else: clauses.append(child) if not clauses and not items: - return true_rule or False_.Instance(player=self.player) + return true_rule or False_.Resolved(player=self.player) if items: if len(items) == 1: - item_rule = Has.Instance(items[0], player=self.player) + item_rule = Has.Resolved(items[0], player=self.player) else: - item_rule = HasAll.Instance(tuple(items), player=self.player) + item_rule = HasAll.Resolved(tuple(items), player=self.player) if not clauses: return item_rule clauses.append(item_rule) if len(clauses) == 1: return clauses[0] - return And.Instance( + return And.Resolved( tuple(clauses), player=self.player, cacheable=self.cacheable and all(c.cacheable for c in clauses), @@ -195,16 +225,16 @@ def simplify(self) -> "Rule.Instance": @dataclasses.dataclass(init=False) class Or(NestedRule): @dataclasses.dataclass(frozen=True) - class Instance(NestedRule.Instance): + class Resolved(NestedRule.Resolved): def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: if rule.test(state): return True return False - def simplify(self) -> "Rule.Instance": + def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) - clauses: list[Rule.Instance] = [] + clauses: list[Rule.Resolved] = [] items: list[str] = [] while children_to_process: @@ -215,31 +245,31 @@ def simplify(self) -> "Rule.Instance": if child.always_false: # falses can be ignored continue - if isinstance(child, Or.Instance): + if isinstance(child, Or.Resolved): children_to_process.extend(child.children) continue - if isinstance(child, Has.Instance) and child.count == 1: + if isinstance(child, Has.Resolved) and child.count == 1: items.append(child.item) - elif isinstance(child, HasAny.Instance): + elif isinstance(child, HasAny.Resolved): items.extend(child.items) else: clauses.append(child) if not clauses and not items: - return False_.Instance(player=self.player) + return False_.Resolved(player=self.player) if items: if len(items) == 1: - item_rule = Has.Instance(items[0], player=self.player) + item_rule = Has.Resolved(items[0], player=self.player) else: - item_rule = HasAny.Instance(tuple(items), player=self.player) + item_rule = HasAny.Resolved(tuple(items), player=self.player) if not clauses: return item_rule clauses.append(item_rule) if len(clauses) == 1: return clauses[0] - return Or.Instance( + return Or.Resolved( tuple(clauses), player=self.player, cacheable=self.cacheable and all(c.cacheable for c in clauses), @@ -252,7 +282,7 @@ class Has(Rule): count: int = 1 @dataclasses.dataclass(frozen=True) - class Instance(Rule.Instance): + class Resolved(Rule.Resolved): item: str count: int = 1 @@ -265,10 +295,13 @@ def item_dependencies(self) -> dict[str, set[int]]: @dataclasses.dataclass() class HasAll(Rule): + """A rule that checks if the player has all of the given items""" + items: tuple[str, ...] + """A tuple of item names to check for""" @dataclasses.dataclass(frozen=True) - class Instance(Rule.Instance): + class Resolved(Rule.Resolved): items: tuple[str, ...] def _evaluate(self, state: "CollectionState") -> bool: @@ -280,10 +313,13 @@ def item_dependencies(self) -> dict[str, set[int]]: @dataclasses.dataclass() class HasAny(Rule): + """A rule that checks if the player has at least one of the given items""" + items: tuple[str, ...] + """A tuple of item names to check for""" @dataclasses.dataclass(frozen=True) - class Instance(Rule.Instance): + class Resolved(Rule.Resolved): items: tuple[str, ...] def _evaluate(self, state: "CollectionState") -> bool: @@ -291,3 +327,98 @@ def _evaluate(self, state: "CollectionState") -> bool: def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.items} + + +@dataclasses.dataclass() +class CanReachLocation(Rule): + location_name: str + + def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + location = world.get_location(self.location_name) + if not location.parent_region: + raise ValueError(f"Location {location.name} has no parent region") + return self.Resolved(self.location_name, location.parent_region.name, player=world.player) + + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + location_name: str + parent_region_name: str + cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + + def _evaluate(self, state: "CollectionState") -> bool: + return state.can_reach_location(self.location_name, self.player) + + def indirect_regions(self) -> tuple[str, ...]: + return (self.parent_region_name,) + + +@dataclasses.dataclass() +class CanReachRegion(Rule): + region_name: str + + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + region_name: str + cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + + def _evaluate(self, state: "CollectionState") -> bool: + return state.can_reach_region(self.region_name, self.player) + + def indirect_regions(self) -> tuple[str, ...]: + return (self.region_name,) + + +@dataclasses.dataclass() +class CanReachEntrance(Rule): + entrance_name: str + + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + entrance_name: str + cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + + def _evaluate(self, state: "CollectionState") -> bool: + return state.can_reach_entrance(self.entrance_name, self.player) + + +class RuleWorldMixin(World if TYPE_CHECKING else object): + rule_ids: dict[int, Rule.Resolved] + rule_dependencies: dict[str, set[int]] + rule_classes: dict[str, type[Rule]] + + def __init__(self, multiworld: "MultiWorld", player: int) -> None: + super().__init__(multiworld, player) + self.rule_ids = {} + self.rule_dependencies = defaultdict(set) + + def register_rule_class(self, rule_class: type[Rule]) -> None: + self.rule_classes[rule_class.__name__] = rule_class + + def rule_from_json(self, rule_data: Any) -> "Rule": + pass + + def resolve_rule(self, rule: "Rule") -> "Rule.Resolved": + resolved_rule = rule.resolve(self) + for item_name, rule_ids in resolved_rule.item_dependencies().items(): + self.rule_dependencies[item_name] |= rule_ids + return resolved_rule + + def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: + for indirect_region in resolved_rule.indirect_regions(): + self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) + + def collect(self, state: "CollectionState", item: "Item") -> bool: + changed = super().collect(state, item) + if changed and getattr(self, "rule_dependencies", None): + player_results: dict[int, bool] = state.rule_cache[self.player] + for rule_id in self.rule_dependencies[item.name]: + player_results.pop(rule_id, None) + return changed + + def remove(self, state: "CollectionState", item: "Item") -> bool: + changed = super().remove(state, item) + if changed and getattr(self, "rule_dependencies", None): + player_results: dict[int, bool] = state.rule_cache[self.player] + for rule_id in self.rule_dependencies[item.name]: + player_results.pop(rule_id, None) + return changed From ee575e91f2c4ae77e1f022e190c87af3dce76f65 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 25 May 2025 20:41:39 -0400 Subject: [PATCH 003/135] add docs and fill out rest of the functionality --- docs/rule builder.md | 104 ++++++++++++++++++++++++++++++++++++ rules.py => rule_builder.py | 55 +++++++++++++++---- 2 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 docs/rule builder.md rename rules.py => rule_builder.py (89%) diff --git a/docs/rule builder.md b/docs/rule builder.md new file mode 100644 index 000000000000..2a3fc5d131eb --- /dev/null +++ b/docs/rule builder.md @@ -0,0 +1,104 @@ +# Rule Builder + +This document describes the API provided for the rule builder. Using this API prvoides you with with a simple interface to define rules and the following advantages: + +- Automatic result caching +- Branch pruning based on selected options +- Serialize/deserialize to JSON +- Human-readable logic explanations + +## Usage + +The rule builder provides a `RuleWorldMixin` for your `World` class that provides some helpers for you. + +```python +class MyWorld(RuleWorldMixin, World): + game = "My Game" +``` + +The rule builder comes with a few by default: + +- `True_`: Always returns true +- `False_`: Always returns false +- `And`: Checks that all child rules are true +- `Or`: Checks that at least one child rule is true +- `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 +- `CanReachLocation`: Checks that the player can reach the given location +- `CanReachRegion`: Checks that the player can reach the given region +- `CanReachEntrance`: Checks that the player can 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: + +```python +rule = Or( + Has("Movement ability"), + HasAll("Key 1", "Key 2"), +) +``` + +When assigning the rule you must resolve the rule against the current world, and then assign the `.test` method as the `access_rule`. + +```python +resolved_rule = self.resolve_rule(rule) +set_rule(location, resolved_rule.test) +``` + +## Restricting options + +Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is a dictionary of option name to expected value. If you want a comparison that isn't equals, you can add the operator name after a double underscore after the option name. + +To check if the player can reach a switch, or if they've receieved the switch item if switches are randomized: + +```python +Or( + Has("Red switch", options={"switch_rando": 1}), + CanReachLocation("Red switch", options={"switch_rando": 0}), +) +``` + +To add an extra logic requirement on the easiest difficulty: + +```python +And( + # the rest of the logic + Or( + Has("QoL item", options={"difficulty": 0}), + True_(options={"difficulty__ge": 1}), + ), +) +``` + +## 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 a `Resolved` child class that defines an `_evaluate` method. You may need to also define an `item_dependencies` or `indirect_regions` function. + +To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement: + +```python +@dataclasses.dataclass() +class CanGoal(Rule): + def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + return self.Resolved(world.required_mcguffins, player=world.player) + + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + goal: int + + def _evaluate(self, state: "CollectionState") -> bool: + return state.has("McGuffin", self.player, count=self.goal) + + def item_dependencies(self) -> dict[str, set[int]]: + return {"McGuffin": {id(self)}} +``` + +If you want to use the serialization, you must add a `custom_rule_classes` class var to your world that points to the custom rules you've defined. + +```python +class MyWorld(RuleWorldMixin, World): + game = "My Game" + custom_rule_classes = { + "CanGoal": CanGoal, + } +``` diff --git a/rules.py b/rule_builder.py similarity index 89% rename from rules.py rename to rule_builder.py index c1e31cef5571..8fcd84de5068 100644 --- a/rules.py +++ b/rule_builder.py @@ -2,7 +2,8 @@ import itertools import operator from collections import defaultdict -from typing import TYPE_CHECKING, Any, ClassVar +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Self from worlds.AutoWorld import World @@ -27,7 +28,7 @@ class Rule: options: dict[str, Any] = dataclasses.field(default_factory=dict, kw_only=True) """A mapping of option_name to value to restrict what options are required for this rule to be active. - An operator can be specified with a double underscore and the operator after the option name, eg `name__le` + An operator can be specified with a double underscore and the operator after the option name, eg `opt__le` """ def _passes_options(self, options: "CommonOptions") -> bool: @@ -56,13 +57,19 @@ def resolve(self, world: "RuleWorldMixin") -> "Resolved": world.rule_ids[rule_hash] = instance return world.rule_ids[rule_hash] - def to_json(self) -> dict[str, Any]: + def to_json(self) -> Any: """Returns a JSON-serializable definition of this rule""" return { - "type": self.__class__.__name__, + "rule": self.__class__.__name__, "args": {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)}, } + @classmethod + def from_json(cls, data: Any) -> Self: + if not isinstance(data, Mapping): + raise ValueError("Invalid data format for parsed json") + return cls(**data.get("args", {})) + @dataclasses.dataclass(kw_only=True, frozen=True) class Resolved: """A resolved rule for a given world that can be used as an access rule""" @@ -145,6 +152,18 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": children = [c.resolve(world) for c in self.children] return self.Resolved(tuple(children), player=world.player).simplify() + def to_json(self) -> Any: + return { + "rule": self.__class__.__name__, + "children": [c.to_json() for c in self.children], + } + + @classmethod + def from_json(cls, data: Any) -> Self: + if not isinstance(data, Mapping): + raise ValueError("Invalid data format for parsed json") + return cls(*data.get("children", [])) + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" @@ -384,18 +403,27 @@ def _evaluate(self, state: "CollectionState") -> bool: class RuleWorldMixin(World if TYPE_CHECKING else object): rule_ids: dict[int, Rule.Resolved] rule_dependencies: dict[str, set[int]] - rule_classes: dict[str, type[Rule]] + + custom_rule_classes: ClassVar[dict[str, type[Rule]]] def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} self.rule_dependencies = defaultdict(set) - def register_rule_class(self, rule_class: type[Rule]) -> None: - self.rule_classes[rule_class.__name__] = rule_class - - def rule_from_json(self, rule_data: Any) -> "Rule": - pass + @classmethod + def rule_from_json(cls, data: Any) -> "Rule": + if not isinstance(data, Mapping): + raise ValueError("Invalid data format for parsed json") + name = data.get("rule", "") + if name not in DEFAULT_RULES and ( + not getattr(cls, "custom_rule_classes", None) or name not in cls.custom_rule_classes + ): + raise ValueError("Rule not found") + rule_class = DEFAULT_RULES.get(name) or cls.custom_rule_classes[name] + if not issubclass(rule_class, Rule): + raise ValueError("Invalid rule") + return rule_class.from_json(data) def resolve_rule(self, rule: "Rule") -> "Rule.Resolved": resolved_rule = rule.resolve(self) @@ -422,3 +450,10 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: for rule_id in self.rule_dependencies[item.name]: player_results.pop(rule_id, None) return changed + + +DEFAULT_RULES = { + rule_name: rule_class + for rule_name, rule_class in locals().items() + if isinstance(rule_class, type) and issubclass(rule_class, Rule) and rule_class is not Rule +} From da19a1b033fa088ec08f86c0537ecf0c13a6c662 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 25 May 2025 21:11:49 -0400 Subject: [PATCH 004/135] add in explain functions --- rule_builder.py | 147 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 17 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 8fcd84de5068..ba7bbd06da7e 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from BaseClasses import CollectionState, Entrance, Item, MultiWorld + from NetUtils import JSONMessagePart from Options import CommonOptions, Option OPERATORS = { @@ -117,6 +118,9 @@ def indirect_regions(self) -> tuple[str, ...]: """Returns a tuple of region names this rule is indirectly connected to""" return () + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [{"type": "text", "text": self.__class__.__name__}] + @dataclasses.dataclass() class True_(Rule): @@ -128,6 +132,9 @@ class Resolved(Rule.Resolved): def _evaluate(self, state: "CollectionState") -> bool: return True + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [{"type": "color", "color": "green", "text": "True"}] + @dataclasses.dataclass() class False_(Rule): @@ -139,6 +146,9 @@ class Resolved(Rule.Resolved): def _evaluate(self, state: "CollectionState") -> bool: return False + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [{"type": "color", "color": "salmon", "text": "False"}] + @dataclasses.dataclass(init=False) class NestedRule(Rule): @@ -195,6 +205,15 @@ def _evaluate(self, state: "CollectionState") -> bool: return False return True + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] + for i, child in enumerate(self.children): + if i > 0: + messages.append({"type": "text", "text": " & "}) + messages.extend(child.explain(state)) + messages.append({"type": "text", "text": ")"}) + return messages + def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) clauses: list[Rule.Resolved] = [] @@ -215,9 +234,9 @@ def simplify(self) -> "Rule.Resolved": continue if isinstance(child, Has.Resolved) and child.count == 1: - items.append(child.item) + items.append(child.item_name) elif isinstance(child, HasAll.Resolved): - items.extend(child.items) + items.extend(child.item_names) else: clauses.append(child) @@ -251,6 +270,15 @@ def _evaluate(self, state: "CollectionState") -> bool: return True return False + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] + for i, child in enumerate(self.children): + if i > 0: + messages.append({"type": "text", "text": " | "}) + messages.extend(child.explain(state)) + messages.append({"type": "text", "text": ")"}) + return messages + def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) clauses: list[Rule.Resolved] = [] @@ -269,9 +297,9 @@ def simplify(self) -> "Rule.Resolved": continue if isinstance(child, Has.Resolved) and child.count == 1: - items.append(child.item) + items.append(child.item_name) elif isinstance(child, HasAny.Resolved): - items.extend(child.items) + items.extend(child.item_names) else: clauses.append(child) @@ -297,55 +325,116 @@ def simplify(self) -> "Rule.Resolved": @dataclasses.dataclass() class Has(Rule): - item: str + item_name: str count: int = 1 + def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + return self.Resolved(self.item_name, self.count, player=world.player) + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): - item: str + item_name: str count: int = 1 def _evaluate(self, state: "CollectionState") -> bool: - return state.has(self.item, self.player, count=self.count) + return state.has(self.item_name, self.player, count=self.count) def item_dependencies(self) -> dict[str, set[int]]: - return {self.item: {id(self)}} + return {self.item_name: {id(self)}} + + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] + if self.count > 1: + messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) + messages.append({"type": "text", "text": "x "}) + messages.append( + {"type": "item_name", "flags": 0b001, "text": self.item_name, "player": self.player} + ) + return messages -@dataclasses.dataclass() +@dataclasses.dataclass(init=False) class HasAll(Rule): """A rule that checks if the player has all of the given items""" - items: tuple[str, ...] + item_names: tuple[str, ...] """A tuple of item names to check for""" + def __init__(self, *item_names: str, options: dict[str, Any] | None = None) -> None: + super().__init__(options=options or {}) + self.item_names = item_names + + def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": + if len(self.item_names) == 0: + return True_().resolve(world) + if len(self.item_names) == 1: + return Has(self.item_names[0]).resolve(world) + return self.Resolved(self.item_names, player=world.player) + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): - items: tuple[str, ...] + item_names: tuple[str, ...] def _evaluate(self, state: "CollectionState") -> bool: - return state.has_all(self.items, self.player) + return state.has_all(self.item_names, self.player) def item_dependencies(self) -> dict[str, set[int]]: - return {item: {id(self)} for item in self.items} + return {item: {id(self)} for item in self.item_names} + + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": ")"}) + return messages @dataclasses.dataclass() class HasAny(Rule): """A rule that checks if the player has at least one of the given items""" - items: tuple[str, ...] + item_names: tuple[str, ...] """A tuple of item names to check for""" + def __init__(self, *item_names: str, options: dict[str, Any] | None = None) -> None: + super().__init__(options=options or {}) + self.item_names = item_names + + def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": + if len(self.item_names) == 0: + return True_().resolve(world) + if len(self.item_names) == 1: + return Has(self.item_names[0]).resolve(world) + return self.Resolved(self.item_names, player=world.player) + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): - items: tuple[str, ...] + item_names: tuple[str, ...] def _evaluate(self, state: "CollectionState") -> bool: - return state.has_any(self.items, self.player) + return state.has_any(self.item_names, self.player) def item_dependencies(self) -> dict[str, set[int]]: - return {item: {id(self)} for item in self.items} + return {item: {id(self)} for item in self.item_names} + + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "any"}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": ")"}) + return messages @dataclasses.dataclass() @@ -370,11 +459,20 @@ def _evaluate(self, state: "CollectionState") -> bool: def indirect_regions(self) -> tuple[str, ...]: return (self.parent_region_name,) + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [ + {"type": "text", "text": "Reached Location "}, + {"type": "location_name", "text": self.location_name, "player": self.player}, + ] + @dataclasses.dataclass() class CanReachRegion(Rule): region_name: str + def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + return self.Resolved(self.region_name, player=world.player) + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): region_name: str @@ -386,11 +484,20 @@ def _evaluate(self, state: "CollectionState") -> bool: def indirect_regions(self) -> tuple[str, ...]: return (self.region_name,) + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [ + {"type": "text", "text": "Reached Region "}, + {"type": "color", "color": "yellow", "text": self.region_name}, + ] + @dataclasses.dataclass() class CanReachEntrance(Rule): entrance_name: str + def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + return self.Resolved(self.entrance_name, player=world.player) + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): entrance_name: str @@ -399,6 +506,12 @@ class Resolved(Rule.Resolved): def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_entrance(self.entrance_name, self.player) + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [ + {"type": "text", "text": "Reached Entrance "}, + {"type": "entrance_name", "text": self.entrance_name, "player": self.player}, + ] + class RuleWorldMixin(World if TYPE_CHECKING else object): rule_ids: dict[int, Rule.Resolved] From 4b565ce49081ebfb35a32ebb2978878070bd04bc Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 25 May 2025 23:51:36 -0400 Subject: [PATCH 005/135] dedupe items and add more docs --- docs/rule builder.md | 48 +++++++++++++++++++++++++++++++++++++++++++- rule_builder.py | 19 +++++++++--------- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 2a3fc5d131eb..054dc8d5892e 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -3,7 +3,7 @@ This document describes the API provided for the rule builder. Using this API prvoides you with with a simple interface to define rules and the following advantages: - Automatic result caching -- Branch pruning based on selected options +- Logic optimization - Serialize/deserialize to JSON - Human-readable logic explanations @@ -102,3 +102,49 @@ class MyWorld(RuleWorldMixin, World): "CanGoal": CanGoal, } ``` + +## JSON serialization + +The rule builder is intended to be written first in Python for optimization and type safety. To export the rules to a client or tracker, there is a default JSON serializer implementation for all rules. By default the rules will export with the following format: + +```json +{ + "rule": "", + "args": { + "options": {...}, + "": // for each field the rule defines + } +} +``` + +The `And` and `Or` rules have a slightly different format: + +```json +{ + "rule": "And", + "options": {...}, + "children": [ + {} + ] +} +``` + +To define a custom format, override the `to_json` function: + +```python +class MyRule(Rule): + def to_json(self) -> Any: + return { + "rule": "my_rule", + "custom_logic": [...] + } +``` + +If your logic has been done in custom JSON first, you can define a `from_json` class method on your rules to parse it correctly: + +```python +class MyRule(Rule): + @classmethod + def from_json(cls, data: Any) -> Self: + return cls(data.get("custom_logic")) +``` diff --git a/rule_builder.py b/rule_builder.py index ba7bbd06da7e..d603e001d604 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -165,6 +165,7 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": def to_json(self) -> Any: return { "rule": self.__class__.__name__, + "options": self.options, "children": [c.to_json() for c in self.children], } @@ -172,7 +173,7 @@ def to_json(self) -> Any: def from_json(cls, data: Any) -> Self: if not isinstance(data, Mapping): raise ValueError("Invalid data format for parsed json") - return cls(*data.get("children", [])) + return cls(*data.get("children", []), options=data.get("options")) @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): @@ -217,7 +218,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) clauses: list[Rule.Resolved] = [] - items: list[str] = [] + items: set[str] = set() true_rule: Rule.Resolved | None = None while children_to_process: @@ -234,9 +235,9 @@ def simplify(self) -> "Rule.Resolved": continue if isinstance(child, Has.Resolved) and child.count == 1: - items.append(child.item_name) + items.add(child.item_name) elif isinstance(child, HasAll.Resolved): - items.extend(child.item_names) + items.update(child.item_names) else: clauses.append(child) @@ -244,7 +245,7 @@ def simplify(self) -> "Rule.Resolved": return true_rule or False_.Resolved(player=self.player) if items: if len(items) == 1: - item_rule = Has.Resolved(items[0], player=self.player) + item_rule = Has.Resolved(items.pop(), player=self.player) else: item_rule = HasAll.Resolved(tuple(items), player=self.player) if not clauses: @@ -282,7 +283,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) clauses: list[Rule.Resolved] = [] - items: list[str] = [] + items: set[str] = set() while children_to_process: child = children_to_process.pop(0) @@ -297,9 +298,9 @@ def simplify(self) -> "Rule.Resolved": continue if isinstance(child, Has.Resolved) and child.count == 1: - items.append(child.item_name) + items.add(child.item_name) elif isinstance(child, HasAny.Resolved): - items.extend(child.item_names) + items.update(child.item_names) else: clauses.append(child) @@ -307,7 +308,7 @@ def simplify(self) -> "Rule.Resolved": return False_.Resolved(player=self.player) if items: if len(items) == 1: - item_rule = Has.Resolved(items[0], player=self.player) + item_rule = Has.Resolved(items.pop(), player=self.player) else: item_rule = HasAny.Resolved(tuple(items), player=self.player) if not clauses: From cae61e92ba44c73687966e535b764c8d787f9fe3 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Jun 2025 23:20:06 -0400 Subject: [PATCH 006/135] pr feedback and optimization updates --- .github/pyright-config.json | 2 + docs/rule builder.md | 33 +++++- rule_builder.py | 174 +++++++++++++++++++++--------- test/general/test_rule_builder.py | 48 +++++++++ 4 files changed, 201 insertions(+), 56 deletions(-) create mode 100644 test/general/test_rule_builder.py diff --git a/.github/pyright-config.json b/.github/pyright-config.json index b6561afa4662..9e4903b5061d 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -2,11 +2,13 @@ "include": [ "../BizHawkClient.py", "../Patch.py", + "../rule_builder.py", "../test/param.py", "../test/general/test_groups.py", "../test/general/test_helpers.py", "../test/general/test_memory.py", "../test/general/test_names.py", + "../test/general/test_rule_builder.py", "../test/multiworld/__init__.py", "../test/multiworld/test_multiworlds.py", "../test/netutils/__init__.py", diff --git a/docs/rule builder.md b/docs/rule builder.md index 054dc8d5892e..c3a1bbc31670 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -38,17 +38,26 @@ rule = Or( ) ``` -When assigning the rule you must resolve the rule against the current world, and then assign the `.test` method as the `access_rule`. +When assigning the rule you must use the `set_rule` helper added by the rule mixing to correctly resolve and register the rule. ```python -resolved_rule = self.resolve_rule(rule) -set_rule(location, resolved_rule.test) +self.set_rule(location_or_entrance, rule) ``` ## Restricting options Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is a dictionary of option name to expected value. If you want a comparison that isn't equals, you can add the operator name after a double underscore after the option name. +The following operators are allowed: + +- `eq`: `==` +- `ne`: `!=` +- `gt`: `>` +- `lt`: `<` +- `ge`: `>=` +- `le`: `<=` +- `contains`: `in` + To check if the player can reach a switch, or if they've receieved the switch item if switches are randomized: ```python @@ -148,3 +157,21 @@ class MyRule(Rule): def from_json(cls, data: Any) -> Self: return cls(data.get("custom_logic")) ``` + +## Rule explanations + +Resolved rules have a default implementation for an `explain` message, which 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. + +To implement a custom message with a custom rule, override the `explain` method on your `Resolved` class: + +```python +class MyRule(Rule): + class Resolved(Rule.Resolved): + @override + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + return [ + {"type": "text", "text": "You must be "}, + {"type": "color", "color": "green", "text": "THIS"}, + {"type": "text", "text": " tall to beat the game"}, + ] +``` diff --git a/rule_builder.py b/rule_builder.py index d603e001d604..0257a68a989f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -3,14 +3,19 @@ import operator from collections import defaultdict from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self, cast -from worlds.AutoWorld import World +from typing_extensions import override + +from BaseClasses import Entrance if TYPE_CHECKING: - from BaseClasses import CollectionState, Entrance, Item, MultiWorld + from BaseClasses import CollectionState, Item, Location, MultiWorld from NetUtils import JSONMessagePart from Options import CommonOptions, Option + from worlds.AutoWorld import World +else: + World = object OPERATORS = { "eq": operator.eq, @@ -25,7 +30,7 @@ @dataclasses.dataclass() class Rule: - """Base class for a static rule used to generate a""" + """Base class for a static rule used to generate an access rule""" options: dict[str, Any] = dataclasses.field(default_factory=dict, kw_only=True) """A mapping of option_name to value to restrict what options are required for this rule to be active. @@ -36,11 +41,19 @@ def _passes_options(self, options: "CommonOptions") -> bool: """Tests if the given world options pass the requirements for this rule""" for key, value in self.options: parts = key.split("__", maxsplit=1) + option_name = parts[0] + opt = cast("Option[Any] | None", getattr(options, option_name, None)) + if opt is None: + raise ValueError(f"Invalid option: {option_name}") + operator = parts[1] if len(parts) > 1 else "eq" - opt: Option[Any] = getattr(options, option_name) + if operator not in OPERATORS: + raise ValueError(f"Invalid operator: {operator}") + if not OPERATORS[operator](opt.value, value): return False + return True def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": @@ -58,7 +71,7 @@ def resolve(self, world: "RuleWorldMixin") -> "Resolved": world.rule_ids[rule_hash] = instance return world.rule_ids[rule_hash] - def to_json(self) -> Any: + def to_json(self) -> Mapping[str, Any]: """Returns a JSON-serializable definition of this rule""" return { "rule": self.__class__.__name__, @@ -66,9 +79,7 @@ def to_json(self) -> Any: } @classmethod - def from_json(cls, data: Any) -> Self: - if not isinstance(data, Mapping): - raise ValueError("Invalid data format for parsed json") + def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(**data.get("args", {})) @dataclasses.dataclass(kw_only=True, frozen=True) @@ -81,12 +92,13 @@ class Resolved: cacheable: bool = dataclasses.field(repr=False, default=True) """If this rule should be cached in the state""" - always_true: ClassVar = False + always_true: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" - always_false: ClassVar = False + always_false: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" + @override def __hash__(self) -> int: return hash((self.__class__.__name__, *[getattr(self, f.name) for f in dataclasses.fields(self)])) @@ -119,33 +131,42 @@ def indirect_regions(self) -> tuple[str, ...]: return () def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + """Returns a list of printJSON messages that explain the logic for this rule""" return [{"type": "text", "text": self.__class__.__name__}] @dataclasses.dataclass() class True_(Rule): + """A rule that always returns True""" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - always_true = True + always_true: ClassVar[bool] = True + @override def _evaluate(self, state: "CollectionState") -> bool: return True + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [{"type": "color", "color": "green", "text": "True"}] @dataclasses.dataclass() class False_(Rule): + """A rule that always returns False""" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - always_false = True + always_false: ClassVar[bool] = True + @override def _evaluate(self, state: "CollectionState") -> bool: return False + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [{"type": "color", "color": "salmon", "text": "False"}] @@ -158,27 +179,29 @@ def __init__(self, *children: "Rule", options: dict[str, Any] | None = None) -> super().__init__(options=options or {}) self.children = children + @override def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": children = [c.resolve(world) for c in self.children] return self.Resolved(tuple(children), player=world.player).simplify() - def to_json(self) -> Any: + @override + def to_json(self) -> Mapping[str, Any]: return { "rule": self.__class__.__name__, "options": self.options, "children": [c.to_json() for c in self.children], } + @override @classmethod - def from_json(cls, data: Any) -> Self: - if not isinstance(data, Mapping): - raise ValueError("Invalid data format for parsed json") + def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(*data.get("children", []), options=data.get("options")) @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" + @override def item_dependencies(self) -> dict[str, set[int]]: combined_deps: dict[str, set[int]] = {} for child in self.children: @@ -189,6 +212,7 @@ def item_dependencies(self) -> dict[str, set[int]]: combined_deps[item_name] = {id(self), *rules} return combined_deps + @override def indirect_regions(self) -> tuple[str, ...]: return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) @@ -200,12 +224,14 @@ def simplify(self) -> "Rule.Resolved": class And(NestedRule): @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): + @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: if not rule.test(state): return False return True + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] for i, child in enumerate(self.children): @@ -215,10 +241,11 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa messages.append({"type": "text", "text": ")"}) return messages + @override def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) clauses: list[Rule.Resolved] = [] - items: set[str] = set() + items: dict[str, int] = {} true_rule: Rule.Resolved | None = None while children_to_process: @@ -234,23 +261,30 @@ def simplify(self) -> "Rule.Resolved": children_to_process.extend(child.children) continue - if isinstance(child, Has.Resolved) and child.count == 1: - items.add(child.item_name) + if isinstance(child, Has.Resolved): + if child.item_name not in items or items[child.item_name] < child.count: + items[child.item_name] = child.count elif isinstance(child, HasAll.Resolved): - items.update(child.item_names) + for item in child.item_names: + if item not in items: + items[item] = 1 else: clauses.append(child) if not clauses and not items: return true_rule or False_.Resolved(player=self.player) - if items: - if len(items) == 1: - item_rule = Has.Resolved(items.pop(), player=self.player) + + has_all_items: list[str] = [] + for item, count in items.items(): + if count == 1: + has_all_items.append(item) else: - item_rule = HasAll.Resolved(tuple(items), player=self.player) - if not clauses: - return item_rule - clauses.append(item_rule) + clauses.append(Has.Resolved(item, count, player=self.player)) + + if len(has_all_items) == 1: + clauses.append(Has.Resolved(has_all_items[0], player=self.player)) + elif len(has_all_items) > 1: + clauses.append(HasAll.Resolved(tuple(has_all_items), player=self.player)) if len(clauses) == 1: return clauses[0] @@ -265,12 +299,14 @@ def simplify(self) -> "Rule.Resolved": class Or(NestedRule): @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): + @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: if rule.test(state): return True return False + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] for i, child in enumerate(self.children): @@ -280,10 +316,11 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa messages.append({"type": "text", "text": ")"}) return messages + @override def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) clauses: list[Rule.Resolved] = [] - items: set[str] = set() + items: dict[str, int] = {} while children_to_process: child = children_to_process.pop(0) @@ -297,23 +334,29 @@ def simplify(self) -> "Rule.Resolved": children_to_process.extend(child.children) continue - if isinstance(child, Has.Resolved) and child.count == 1: - items.add(child.item_name) + if isinstance(child, Has.Resolved): + if child.item_name not in items or child.count < items[child.item_name]: + items[child.item_name] = child.count elif isinstance(child, HasAny.Resolved): - items.update(child.item_names) + for item in child.item_names: + items[item] = 1 else: clauses.append(child) if not clauses and not items: return False_.Resolved(player=self.player) - if items: - if len(items) == 1: - item_rule = Has.Resolved(items.pop(), player=self.player) + + has_any_items: list[str] = [] + for item, count in items.items(): + if count == 1: + has_any_items.append(item) else: - item_rule = HasAny.Resolved(tuple(items), player=self.player) - if not clauses: - return item_rule - clauses.append(item_rule) + clauses.append(Has.Resolved(item, count, player=self.player)) + + if len(has_any_items) == 1: + clauses.append(Has.Resolved(has_any_items[0], player=self.player)) + elif len(has_any_items) > 1: + clauses.append(HasAny.Resolved(tuple(has_any_items), player=self.player)) if len(clauses) == 1: return clauses[0] @@ -329,6 +372,7 @@ class Has(Rule): item_name: str count: int = 1 + @override def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": return self.Resolved(self.item_name, self.count, player=world.player) @@ -337,12 +381,15 @@ class Resolved(Rule.Resolved): item_name: str count: int = 1 + @override def _evaluate(self, state: "CollectionState") -> bool: return state.has(self.item_name, self.player, count=self.count) + @override def item_dependencies(self) -> dict[str, set[int]]: return {self.item_name: {id(self)}} + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] if self.count > 1: @@ -363,8 +410,9 @@ class HasAll(Rule): def __init__(self, *item_names: str, options: dict[str, Any] | None = None) -> None: super().__init__(options=options or {}) - self.item_names = item_names + self.item_names = tuple(sorted(set(item_names))) + @override def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": if len(self.item_names) == 0: return True_().resolve(world) @@ -376,12 +424,15 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": class Resolved(Rule.Resolved): item_names: tuple[str, ...] + @override def _evaluate(self, state: "CollectionState") -> bool: return state.has_all(self.item_names, self.player) + @override def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [ {"type": "text", "text": "Has "}, @@ -405,8 +456,9 @@ class HasAny(Rule): def __init__(self, *item_names: str, options: dict[str, Any] | None = None) -> None: super().__init__(options=options or {}) - self.item_names = item_names + self.item_names = tuple(sorted(set(item_names))) + @override def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": if len(self.item_names) == 0: return True_().resolve(world) @@ -418,12 +470,15 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": class Resolved(Rule.Resolved): item_names: tuple[str, ...] + @override def _evaluate(self, state: "CollectionState") -> bool: return state.has_any(self.item_names, self.player) + @override def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [ {"type": "text", "text": "Has "}, @@ -442,6 +497,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa class CanReachLocation(Rule): location_name: str + @override def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": location = world.get_location(self.location_name) if not location.parent_region: @@ -454,12 +510,15 @@ class Resolved(Rule.Resolved): parent_region_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + @override def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_location(self.location_name, self.player) + @override def indirect_regions(self) -> tuple[str, ...]: return (self.parent_region_name,) + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ {"type": "text", "text": "Reached Location "}, @@ -471,6 +530,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa class CanReachRegion(Rule): region_name: str + @override def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": return self.Resolved(self.region_name, player=world.player) @@ -479,12 +539,15 @@ class Resolved(Rule.Resolved): region_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + @override def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_region(self.region_name, self.player) + @override def indirect_regions(self) -> tuple[str, ...]: return (self.region_name,) + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ {"type": "text", "text": "Reached Region "}, @@ -496,6 +559,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa class CanReachEntrance(Rule): entrance_name: str + @override def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": return self.Resolved(self.entrance_name, player=world.player) @@ -504,9 +568,11 @@ class Resolved(Rule.Resolved): entrance_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + @override def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_entrance(self.entrance_name, self.player) + @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ {"type": "text", "text": "Reached Entrance "}, @@ -514,7 +580,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa ] -class RuleWorldMixin(World if TYPE_CHECKING else object): +class RuleWorldMixin(World): rule_ids: dict[int, Rule.Resolved] rule_dependencies: dict[str, set[int]] @@ -526,17 +592,11 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: self.rule_dependencies = defaultdict(set) @classmethod - def rule_from_json(cls, data: Any) -> "Rule": - if not isinstance(data, Mapping): - raise ValueError("Invalid data format for parsed json") + def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule": name = data.get("rule", "") - if name not in DEFAULT_RULES and ( - not getattr(cls, "custom_rule_classes", None) or name not in cls.custom_rule_classes - ): + if name not in DEFAULT_RULES and name not in getattr(cls, "custom_rule_classes", {}): raise ValueError("Rule not found") - rule_class = DEFAULT_RULES.get(name) or cls.custom_rule_classes[name] - if not issubclass(rule_class, Rule): - raise ValueError("Invalid rule") + rule_class = cls.custom_rule_classes[name] or DEFAULT_RULES.get(name) return rule_class.from_json(data) def resolve_rule(self, rule: "Rule") -> "Rule.Resolved": @@ -549,20 +609,28 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E for indirect_region in resolved_rule.indirect_regions(): self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) + def set_rule(self, spot: "Location | Entrance", rule: "Rule") -> None: + resolved_rule = self.resolve_rule(rule) + spot.access_rule = resolved_rule.test + if isinstance(spot, Entrance): + self.register_rule_connections(resolved_rule, spot) + + @override def collect(self, state: "CollectionState", item: "Item") -> bool: changed = super().collect(state, item) if changed and getattr(self, "rule_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] for rule_id in self.rule_dependencies[item.name]: - player_results.pop(rule_id, None) + _ = player_results.pop(rule_id, None) return changed + @override def remove(self, state: "CollectionState", item: "Item") -> bool: changed = super().remove(state, item) if changed and getattr(self, "rule_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] for rule_id in self.rule_dependencies[item.name]: - player_results.pop(rule_id, None) + _ = player_results.pop(rule_id, None) return changed diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py new file mode 100644 index 000000000000..9f499f17d0ae --- /dev/null +++ b/test/general/test_rule_builder.py @@ -0,0 +1,48 @@ +import unittest +from typing import ClassVar + +from rule_builder import And, Has, HasAll, HasAny, Or, Rule, RuleWorldMixin +from test.general import setup_solo_multiworld +from test.param import classvar_matrix +from worlds.AutoWorld import World + + +class RuleBuilderWorld(RuleWorldMixin, World): + game = "Rule Builder Test Game" + item_name_to_id = {} + location_name_to_id = {} + hidden = True + + +@classvar_matrix( + rule=( + ( + And(Has("A", 1), Has("A", 2)), + Has.Resolved("A", 2, player=1), + ), + ( + And(Has("A"), HasAll("B", "C")), + HasAll.Resolved(("A", "B", "C"), player=1), + ), + ( + Or(Has("A", 1), Has("A", 2)), + Has.Resolved("A", 1, player=1), + ), + ( + Or(Has("A"), HasAny("B", "C")), + HasAny.Resolved(("A", "B", "C"), player=1), + ), + ( + Or(HasAll("A"), HasAll("A", "A")), + Has.Resolved("A", player=1), + ), + ) +) +class TestSimplify(unittest.TestCase): + rule: ClassVar[tuple[Rule, Rule.Resolved]] + + def test_simplify(self) -> None: + multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + world = multiworld.worlds[1] + rule, expected = self.rule + self.assertEqual(rule.resolve(world), expected) # type: ignore From 74472cdf5090512bfb0ed75178807a2a98dad11c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Jun 2025 23:24:00 -0400 Subject: [PATCH 007/135] Self is not in typing on 3.10 --- rule_builder.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 0257a68a989f..55850274af9e 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -3,9 +3,9 @@ import operator from collections import defaultdict from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, ClassVar, Self, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast -from typing_extensions import override +from typing_extensions import Self, override from BaseClasses import Entrance @@ -395,9 +395,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa if self.count > 1: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) messages.append({"type": "text", "text": "x "}) - messages.append( - {"type": "item_name", "flags": 0b001, "text": self.item_name, "player": self.player} - ) + messages.append({"type": "item_name", "flags": 0b001, "text": self.item_name, "player": self.player}) return messages From e770532a345ff2b213c2621381125177ac00b8ec Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Jun 2025 23:52:08 -0400 Subject: [PATCH 008/135] fix test --- test/general/test_rule_builder.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 9f499f17d0ae..8d9ff50b29a4 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -4,6 +4,7 @@ from rule_builder import And, Has, HasAll, HasAny, Or, Rule, RuleWorldMixin from test.general import setup_solo_multiworld from test.param import classvar_matrix +from worlds import network_data_package from worlds.AutoWorld import World @@ -14,6 +15,9 @@ class RuleBuilderWorld(RuleWorldMixin, World): hidden = True +network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() + + @classvar_matrix( rule=( ( From c25079e9b832a3d9e9a09ca18d4f766ccc142263 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 9 Jun 2025 01:06:40 -0400 Subject: [PATCH 009/135] Update docs/rule builder.md Co-authored-by: BadMagic100 --- docs/rule builder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index c3a1bbc31670..a1c64a128694 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -38,7 +38,7 @@ rule = Or( ) ``` -When assigning the rule you must use the `set_rule` helper added by the rule mixing to correctly resolve and register the rule. +When assigning the rule you must use the `set_rule` helper added by the rule mixin to correctly resolve and register the rule. ```python self.set_rule(location_or_entrance, rule) From bb75c3ee80cc8a0a9a98e1bb5f16e63bed498c65 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 9 Jun 2025 01:54:57 -0400 Subject: [PATCH 010/135] pr feedback --- docs/rule builder.md | 40 +++++++++++++++++++++ rule_builder.py | 2 +- test/general/test_rule_builder.py | 59 +++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index a1c64a128694..e4fedec22be3 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -141,6 +141,7 @@ The `And` and `Or` rules have a slightly different format: To define a custom format, override the `to_json` function: ```python +@dataclasses.dataclass() class MyRule(Rule): def to_json(self) -> Any: return { @@ -152,6 +153,7 @@ class MyRule(Rule): If your logic has been done in custom JSON first, you can define a `from_json` class method on your rules to parse it correctly: ```python +@dataclasses.dataclass() class MyRule(Rule): @classmethod def from_json(cls, data: Any) -> Self: @@ -165,7 +167,9 @@ Resolved rules have a default implementation for an `explain` message, which ret To implement a custom message with a custom rule, override the `explain` method on your `Resolved` class: ```python +@dataclasses.dataclass() class MyRule(Rule): + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -175,3 +179,39 @@ class MyRule(Rule): {"type": "text", "text": " tall to beat the game"}, ] ``` + +## Item dependencies + +If 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. + +```python +@dataclasses.dataclass() +class MyRule(Rule): + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + item_name: str + + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {self.item_name: {id(self)}} +``` + +The default `Has`, `HasAll`, and `HasAny` rules define this function already. + +## Indirect connections + +If your custom rule references other regions, it must define an `indirect_regions` function that returns a tuple of region names. These will be collected and indirect connections will be registered when you set this rule on an entrance. + +```python +@dataclasses.dataclass() +class MyRule(Rule): + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + region_name: str + + @override + def indirect_regions(self) -> tuple[str, ...]: + return (self.region_name,) +``` + +The default `CanReachLocation` and `CanReachRegion` rules define this function already. diff --git a/rule_builder.py b/rule_builder.py index 55850274af9e..d928a0a9eb41 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -39,7 +39,7 @@ class Rule: def _passes_options(self, options: "CommonOptions") -> bool: """Tests if the given world options pass the requirements for this rule""" - for key, value in self.options: + for key, value in self.options.items(): parts = key.split("__", maxsplit=1) option_name = parts[0] diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 8d9ff50b29a4..9062e978d14a 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -1,18 +1,42 @@ import unittest -from typing import ClassVar +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar -from rule_builder import And, Has, HasAll, HasAny, Or, Rule, RuleWorldMixin +from Options import Choice, PerGameCommonOptions, Toggle +from rule_builder import And, False_, Has, HasAll, HasAny, Or, Rule, RuleWorldMixin from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package from worlds.AutoWorld import World +if TYPE_CHECKING: + from BaseClasses import MultiWorld + + +class ToggleOption(Toggle): + auto_display_name = True + + +class ChoiceOption(Choice): + option_first = 0 + option_second = 1 + option_third = 2 + default = 0 + + +@dataclass +class RuleBuilderOptions(PerGameCommonOptions): + toggle_option: ToggleOption + choice_option: ChoiceOption + class RuleBuilderWorld(RuleWorldMixin, World): game = "Rule Builder Test Game" item_name_to_id = {} location_name_to_id = {} hidden = True + options_dataclass = RuleBuilderOptions + options: RuleBuilderOptions # type: ignore network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() @@ -50,3 +74,34 @@ def test_simplify(self) -> None: world = multiworld.worlds[1] rule, expected = self.rule self.assertEqual(rule.resolve(world), expected) # type: ignore + + +class TestOptions(unittest.TestCase): + multiworld: "MultiWorld" + world: "RuleBuilderWorld" + + def setUp(self) -> None: + self.multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + self.world = self.multiworld.worlds[1] # type: ignore + return super().setUp() + + def test_option_filtering(self) -> None: + rule = Or(Has("A", options={"toggle_option": 0}), Has("B", options={"toggle_option": 1})) + + self.world.options.toggle_option.value = 0 + self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) + + self.world.options.toggle_option.value = 1 + self.assertEqual(rule.resolve(self.world), Has.Resolved("B", player=1)) + + def test_gt_filtering(self) -> None: + rule = Or(Has("A", options={"choice_option__gt": 1}), False_()) + + self.world.options.choice_option.value = 0 + self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) + + self.world.options.choice_option.value = 1 + self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) + + self.world.options.choice_option.value = 2 + self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) From be39be5926c3bd3fa9486a8539aefd0798379b45 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 9 Jun 2025 01:58:03 -0400 Subject: [PATCH 011/135] love it when CI gives me different results than local --- test/general/test_rule_builder.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 9062e978d14a..a5fd4661a653 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar +from typing_extensions import override + from Options import Choice, PerGameCommonOptions, Toggle from rule_builder import And, False_, Has, HasAll, HasAny, Or, Rule, RuleWorldMixin from test.general import setup_solo_multiworld @@ -80,6 +82,7 @@ class TestOptions(unittest.TestCase): multiworld: "MultiWorld" world: "RuleBuilderWorld" + @override def setUp(self) -> None: self.multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) self.world = self.multiworld.worlds[1] # type: ignore From f74c07409e832da290e16656180854c7ebe3ae8a Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 11 Jun 2025 01:05:38 -0400 Subject: [PATCH 012/135] add composition with bitwise and and or --- rule_builder.py | 73 ++++++++++++++++++++++++++++++- test/general/test_rule_builder.py | 57 ++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index d928a0a9eb41..7bb2d272f1ff 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, ClassVar, cast -from typing_extensions import Self, override +from typing_extensions import Never, Self, override from BaseClasses import Entrance @@ -82,6 +82,38 @@ def to_json(self) -> Mapping[str, Any]: def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(**data.get("args", {})) + def __and__(self, other: "Rule") -> "Rule": + """Combines two rules into an And rule""" + if isinstance(self, And): + if isinstance(other, And): + if self.options == other.options: + return And(*self.children, *other.children, options=self.options) + else: + return And(*self.children, other, options=self.options) + elif isinstance(other, And): + return And(self, *other.children, options=other.options) + return And(self, other) + + def __or__(self, other: "Rule") -> "Rule": + """Combines two rules into an Or rule""" + if isinstance(self, Or): + if isinstance(other, Or): + if self.options == other.options: + return Or(*self.children, *other.children, options=self.options) + else: + return Or(*self.children, other, options=self.options) + elif isinstance(other, Or): + return Or(self, *other.children, options=other.options) + return Or(self, other) + + def __bool__(self) -> Never: + raise TypeError("Use & or | to combine rules, or use `is not None` for boolean tests") + + @override + def __str__(self) -> str: + options = f"options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({options})" + @dataclasses.dataclass(kw_only=True, frozen=True) class Resolved: """A resolved rule for a given world that can be used as an access rule""" @@ -197,6 +229,12 @@ def to_json(self) -> Mapping[str, Any]: def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(*data.get("children", []), options=data.get("options")) + @override + def __str__(self) -> str: + children = ", ".join(str(c) for c in self.children) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({children}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" @@ -376,6 +414,12 @@ class Has(Rule): def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": return self.Resolved(self.item_name, self.count, player=world.player) + @override + def __str__(self) -> str: + count = f", count={self.count}" if self.count > 1 else "" + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.item_name}{count}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_name: str @@ -418,6 +462,12 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] @@ -464,6 +514,12 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] @@ -502,6 +558,11 @@ def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": raise ValueError(f"Location {location.name} has no parent region") return self.Resolved(self.location_name, location.parent_region.name, player=world.player) + @override + def __str__(self) -> str: + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.location_name}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): location_name: str @@ -532,6 +593,11 @@ class CanReachRegion(Rule): def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": return self.Resolved(self.region_name, player=world.player) + @override + def __str__(self) -> str: + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.region_name}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): region_name: str @@ -561,6 +627,11 @@ class CanReachEntrance(Rule): def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": return self.Resolved(self.entrance_name, player=world.player) + @override + def __str__(self) -> str: + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.entrance_name}{options})" + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): entrance_name: str diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index a5fd4661a653..81e2483c504e 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -45,7 +45,7 @@ class RuleBuilderWorld(RuleWorldMixin, World): @classvar_matrix( - rule=( + rules=( ( And(Has("A", 1), Has("A", 2)), Has.Resolved("A", 2, player=1), @@ -69,13 +69,14 @@ class RuleBuilderWorld(RuleWorldMixin, World): ) ) class TestSimplify(unittest.TestCase): - rule: ClassVar[tuple[Rule, Rule.Resolved]] + rules: ClassVar[tuple[Rule, Rule.Resolved]] def test_simplify(self) -> None: multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) world = multiworld.worlds[1] - rule, expected = self.rule - self.assertEqual(rule.resolve(world), expected) # type: ignore + rule, expected = self.rules + resolved_rule = rule.resolve(world) # type: ignore + self.assertEqual(resolved_rule, expected, str(resolved_rule)) class TestOptions(unittest.TestCase): @@ -108,3 +109,51 @@ def test_gt_filtering(self) -> None: self.world.options.choice_option.value = 2 self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) + + +@classvar_matrix( + rules=( + ( + Has("A") & Has("B"), + And(Has("A"), Has("B")), + ), + ( + Has("A") | Has("B"), + Or(Has("A"), Has("B")), + ), + ( + And(Has("A")) & Has("B"), + And(Has("A"), Has("B")), + ), + ( + And(Has("A"), Has("B")) & And(Has("C")), + And(Has("A"), Has("B"), Has("C")), + ), + ( + And(Has("A"), Has("B")) | Or(Has("C"), Has("D")), + Or(And(Has("A"), Has("B")), Has("C"), Has("D")), + ), + ( + Or(Has("A")) | Or(Has("B"), options={"opt": 1}), + Or(Or(Has("A")), Or(Has("B"), options={"opt": 1})), + ), + ( + And(Has("A"), options={"opt": 1}) & And(Has("B"), options={"opt": 1}), + And(Has("A"), Has("B"), options={"opt": 1}), + ), + ( + Has("A") & Has("B") & Has("C"), + And(Has("A"), Has("B"), Has("C")), + ), + ( + Has("A") & Has("B") | Has("C") & Has("D"), + Or(And(Has("A"), Has("B")), And(Has("C"), Has("D"))), + ), + ) +) +class TestComposition(unittest.TestCase): + rules: ClassVar[tuple[Rule, Rule]] + + def test_composition(self) -> None: + combined_rule, expected = self.rules + self.assertEqual(combined_rule, expected, str(combined_rule)) From 873af53a4d72065879321d54ac3165a5976578dc Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 11 Jun 2025 01:44:59 -0400 Subject: [PATCH 013/135] strongly typed option filtering --- docs/rule builder.md | 10 +++--- rule_builder.py | 58 +++++++++++++++++++------------ test/general/test_rule_builder.py | 17 +++++---- 3 files changed, 50 insertions(+), 35 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index e4fedec22be3..696f281bb847 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -46,7 +46,7 @@ self.set_rule(location_or_entrance, rule) ## Restricting options -Every rule allows you to specify which options it's applicable for. You can provide the argument `options` which is a dictionary of option name to expected value. If you want a comparison that isn't equals, you can add the operator name after a double underscore after the option name. +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. If you want a comparison that isn't equals, you can specify with the `operator` arguemnt. The following operators are allowed: @@ -62,8 +62,8 @@ To check if the player can reach a switch, or if they've receieved the switch it ```python Or( - Has("Red switch", options={"switch_rando": 1}), - CanReachLocation("Red switch", options={"switch_rando": 0}), + Has("Red switch", options=[OptionFilter(SwitchRando, 1)]), + CanReachLocation("Red switch", options=[OptionFilter(SwitchRando, 0)]), ) ``` @@ -73,8 +73,8 @@ To add an extra logic requirement on the easiest difficulty: And( # the rest of the logic Or( - Has("QoL item", options={"difficulty": 0}), - True_(options={"difficulty__ge": 1}), + Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)]), + True_(options=[OptionFilter(Difficulty, Difficulty.option_medium, operator="ge")]), ), ) ``` diff --git a/rule_builder.py b/rule_builder.py index 7bb2d272f1ff..65b252bbf4bd 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -2,8 +2,8 @@ import itertools import operator from collections import defaultdict -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, ClassVar, cast +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeVar, cast from typing_extensions import Never, Self, override @@ -17,6 +17,8 @@ else: World = object +Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] + OPERATORS = { "eq": operator.eq, "ne": operator.ne, @@ -27,31 +29,37 @@ "contains": operator.contains, } +T = TypeVar("T") + + +@dataclasses.dataclass(frozen=True) +class OptionFilter(Generic[T]): + option: "type[Option[T]]" + value: T + operator: Operator = "eq" + @dataclasses.dataclass() class Rule: """Base class for a static rule used to generate an access rule""" - options: dict[str, Any] = dataclasses.field(default_factory=dict, kw_only=True) - """A mapping of option_name to value to restrict what options are required for this rule to be active. - An operator can be specified with a double underscore and the operator after the option name, eg `opt__le` - """ + options: "Iterable[OptionFilter[Any]]" = dataclasses.field(default=(), kw_only=True) + """An iterable of OptionFilters to restrict what options are required for this rule to be active""" def _passes_options(self, options: "CommonOptions") -> bool: """Tests if the given world options pass the requirements for this rule""" - for key, value in self.options.items(): - parts = key.split("__", maxsplit=1) - - option_name = parts[0] + for option_filter in self.options: + option_name = next( + (name for name, cls in options.__class__.type_hints.items() if cls is option_filter.option), + None, + ) + if option_name is None: + raise ValueError(f"Cannot find option: {option_filter.option.__name__}") opt = cast("Option[Any] | None", getattr(options, option_name, None)) if opt is None: raise ValueError(f"Invalid option: {option_name}") - operator = parts[1] if len(parts) > 1 else "eq" - if operator not in OPERATORS: - raise ValueError(f"Invalid operator: {operator}") - - if not OPERATORS[operator](opt.value, value): + if not OPERATORS[option_filter.operator](opt.value, option_filter.value): return False return True @@ -73,9 +81,13 @@ def resolve(self, world: "RuleWorldMixin") -> "Resolved": def to_json(self) -> Mapping[str, Any]: """Returns a JSON-serializable definition of this rule""" + args = { + field.name: getattr(self, field.name, None) for field in dataclasses.fields(self) if field.name != "options" + } + args["options"] = [dataclasses.asdict(o) for o in self.options] return { "rule": self.__class__.__name__, - "args": {field.name: getattr(self, field.name, None) for field in dataclasses.fields(self)}, + "args": args, } @classmethod @@ -207,8 +219,8 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa class NestedRule(Rule): children: "tuple[Rule, ...]" - def __init__(self, *children: "Rule", options: dict[str, Any] | None = None) -> None: - super().__init__(options=options or {}) + def __init__(self, *children: "Rule", options: "Iterable[OptionFilter[Any]]" = ()) -> None: + super().__init__(options=options) self.children = children @override @@ -227,7 +239,7 @@ def to_json(self) -> Mapping[str, Any]: @override @classmethod def from_json(cls, data: Mapping[str, Any]) -> Self: - return cls(*data.get("children", []), options=data.get("options")) + return cls(*data.get("children", []), options=data.get("options", ())) @override def __str__(self) -> str: @@ -450,8 +462,8 @@ class HasAll(Rule): item_names: tuple[str, ...] """A tuple of item names to check for""" - def __init__(self, *item_names: str, options: dict[str, Any] | None = None) -> None: - super().__init__(options=options or {}) + def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) @override @@ -502,8 +514,8 @@ class HasAny(Rule): item_names: tuple[str, ...] """A tuple of item names to check for""" - def __init__(self, *item_names: str, options: dict[str, Any] | None = None) -> None: - super().__init__(options=options or {}) + def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) @override diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 81e2483c504e..e1b9f35ffadf 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -5,7 +5,7 @@ from typing_extensions import override from Options import Choice, PerGameCommonOptions, Toggle -from rule_builder import And, False_, Has, HasAll, HasAny, Or, Rule, RuleWorldMixin +from rule_builder import And, False_, Has, HasAll, HasAny, OptionFilter, Or, Rule, RuleWorldMixin from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package @@ -90,7 +90,7 @@ def setUp(self) -> None: return super().setUp() def test_option_filtering(self) -> None: - rule = Or(Has("A", options={"toggle_option": 0}), Has("B", options={"toggle_option": 1})) + rule = Or(Has("A", options=[OptionFilter(ToggleOption, 0)]), Has("B", options=[OptionFilter(ToggleOption, 1)])) self.world.options.toggle_option.value = 0 self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) @@ -99,7 +99,7 @@ def test_option_filtering(self) -> None: self.assertEqual(rule.resolve(self.world), Has.Resolved("B", player=1)) def test_gt_filtering(self) -> None: - rule = Or(Has("A", options={"choice_option__gt": 1}), False_()) + rule = Or(Has("A", options=[OptionFilter(ChoiceOption, 1, operator="gt")]), False_()) self.world.options.choice_option.value = 0 self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) @@ -134,12 +134,15 @@ def test_gt_filtering(self) -> None: Or(And(Has("A"), Has("B")), Has("C"), Has("D")), ), ( - Or(Has("A")) | Or(Has("B"), options={"opt": 1}), - Or(Or(Has("A")), Or(Has("B"), options={"opt": 1})), + Or(Has("A")) | Or(Has("B"), options=[OptionFilter(ToggleOption, 1)]), + Or(Or(Has("A")), Or(Has("B"), options=[OptionFilter(ToggleOption, 1)])), ), ( - And(Has("A"), options={"opt": 1}) & And(Has("B"), options={"opt": 1}), - And(Has("A"), Has("B"), options={"opt": 1}), + ( + And(Has("A"), options=[OptionFilter(ToggleOption, 1)]) + & And(Has("B"), options=[OptionFilter(ToggleOption, 1)]) + ), + And(Has("A"), Has("B"), options=[OptionFilter(ToggleOption, 1)]), ), ( Has("A") & Has("B") & Has("C"), From 0fd5796288895708ad25ee6e5bf90f30275111f3 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 12 Jun 2025 01:47:51 -0400 Subject: [PATCH 014/135] skip resolving location parent region --- rule_builder.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 65b252bbf4bd..88fdc7174222 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -562,13 +562,25 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() class CanReachLocation(Rule): location_name: str + """The name of the location to test access to""" + + parent_region_name: str = "" + """The name of the location's parent region. If not specified it will be resolved when the rule is resolved""" + + skip_indirect_connection: bool = False + """Skip finding the location's parent region. + Do not use this if this rule is for an entrance and explicit_indirect_conditions is True + """ @override def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": - location = world.get_location(self.location_name) - if not location.parent_region: - raise ValueError(f"Location {location.name} has no parent region") - return self.Resolved(self.location_name, location.parent_region.name, player=world.player) + parent_region_name = self.parent_region_name + if not parent_region_name and not self.skip_indirect_connection: + location = world.get_location(self.location_name) + if not location.parent_region: + raise ValueError(f"Location {location.name} has no parent region") + parent_region_name = location.parent_region.name + return self.Resolved(self.location_name, parent_region_name, player=world.player) @override def __str__(self) -> str: @@ -587,7 +599,9 @@ def _evaluate(self, state: "CollectionState") -> bool: @override def indirect_regions(self) -> tuple[str, ...]: - return (self.parent_region_name,) + if self.parent_region_name: + return (self.parent_region_name,) + return () @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -693,7 +707,7 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E def set_rule(self, spot: "Location | Entrance", rule: "Rule") -> None: resolved_rule = self.resolve_rule(rule) spot.access_rule = resolved_rule.test - if isinstance(spot, Entrance): + if self.explicit_indirect_conditions and isinstance(spot, Entrance): self.register_rule_connections(resolved_rule, spot) @override From b45a7164425baeea8c6f3c6e63c8b0eabf086f0d Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 14 Jun 2025 23:56:12 -0400 Subject: [PATCH 015/135] update docs --- docs/rule builder.md | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 696f281bb847..d372312b0869 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -20,24 +20,23 @@ The rule builder comes with a few by default: - `True_`: Always returns true - `False_`: Always returns false -- `And`: Checks that all child rules are true -- `Or`: Checks that at least one child rule is true +- `And`: Checks that all child rules are true (provided by `&` operator) +- `Or`: Checks that at least one child rule is true (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 -- `CanReachLocation`: Checks that the player can reach the given location -- `CanReachRegion`: Checks that the player can reach the given region -- `CanReachEntrance`: Checks that the player can reach the given entrance +- `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: ```python -rule = Or( - Has("Movement ability"), - HasAll("Key 1", "Key 2"), -) +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. In order to check if a rule is defined you must use `if rule is not None`. + When assigning the rule you must use the `set_rule` helper added by the rule mixin to correctly resolve and register the rule. ```python @@ -61,21 +60,30 @@ The following operators are allowed: To check if the player can reach a switch, or if they've receieved the switch item if switches are randomized: ```python -Or( - Has("Red switch", options=[OptionFilter(SwitchRando, 1)]), - CanReachLocation("Red switch", options=[OptionFilter(SwitchRando, 0)]), +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: ```python -And( - # the rest of the logic - Or( - Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)]), - True_(options=[OptionFilter(Difficulty, Difficulty.option_medium, operator="ge")]), - ), +rule = ( + # ...the rest of the logic + & ( + Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)]) + | True_(options=[OptionFilter(Difficulty, Difficulty.option_medium, operator="ge")]) + ) +) +``` + +If you would like to provide option filters when composing rules, you can use the `And` and `Or` rules directly: + +```python +rule = Or( + And(Has("A"), HasAny("B", "C"), options=[OptionFilter(Opt, 0)]), + Or(Has("X"), CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]), ) ``` From 14bc8ac182e902e1f8a1b44cc1a3aa526c95945c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 15 Jun 2025 03:00:36 -0400 Subject: [PATCH 016/135] start conversion to new rule builder --- worlds/astalon/logic/custom_rules.py | 521 ++++++++++++++++++++++++++ worlds/astalon/logic/main_campaign.py | 87 ++--- worlds/astalon/world.py | 74 +--- 3 files changed, 581 insertions(+), 101 deletions(-) create mode 100644 worlds/astalon/logic/custom_rules.py diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py new file mode 100644 index 000000000000..52c65de84e36 --- /dev/null +++ b/worlds/astalon/logic/custom_rules.py @@ -0,0 +1,521 @@ +import dataclasses +from typing import TYPE_CHECKING, Any, ClassVar + +from typing_extensions import override + +import rule_builder + +from ..items import ( + BlueDoor, + Character, + Crystal, + Elevator, + Events, + Eye, + Face, + ItemName, + KeyItem, + RedDoor, + ShopUpgrade, + Switch, + WhiteDoor, +) +from ..options import ( + Difficulty, + Goal, + RandomizeBlueKeys, + RandomizeCharacters, + RandomizeElevator, + RandomizeRedKeys, + RandomizeSwitches, + RandomizeWhiteKeys, +) + +if TYPE_CHECKING: + from collections.abc import Iterable + + from BaseClasses import CollectionState + from NetUtils import JSONMessagePart + from Options import Option + + from ..locations import LocationName + from ..regions import RegionName + from ..world import AstalonWorld + + +ITEM_DEPS: "dict[str, tuple[Character, ...]]" = { + KeyItem.CLOAK.value: (Character.ALGUS,), + KeyItem.SWORD.value: (Character.ARIAS,), + KeyItem.BOOTS.value: (Character.ARIAS,), + KeyItem.CLAW.value: (Character.KYULI,), + KeyItem.BOW.value: (Character.KYULI,), + KeyItem.BLOCK.value: (Character.ZEEK,), + KeyItem.STAR.value: (Character.BRAM,), + KeyItem.BANISH.value: (Character.ALGUS, Character.ZEEK), + KeyItem.GAUNTLET.value: (Character.ARIAS, Character.BRAM), + ShopUpgrade.ALGUS_ARCANIST.value: (Character.ALGUS,), + ShopUpgrade.ALGUS_METEOR.value: (Character.ALGUS,), + ShopUpgrade.ALGUS_SHOCK.value: (Character.ALGUS,), + ShopUpgrade.ARIAS_GORGONSLAYER.value: (Character.ARIAS,), + ShopUpgrade.ARIAS_LAST_STAND.value: (Character.ARIAS,), + ShopUpgrade.ARIAS_LIONHEART.value: (Character.ARIAS,), + ShopUpgrade.KYULI_ASSASSIN.value: (Character.KYULI,), + ShopUpgrade.KYULI_BULLSEYE.value: (Character.KYULI,), + ShopUpgrade.KYULI_RAY.value: (Character.KYULI,), + ShopUpgrade.ZEEK_JUNKYARD.value: (Character.ZEEK,), + ShopUpgrade.ZEEK_ORBS.value: (Character.ZEEK,), + ShopUpgrade.ZEEK_LOOT.value: (Character.ZEEK,), + ShopUpgrade.BRAM_AXE.value: (Character.BRAM,), + ShopUpgrade.BRAM_HUNTER.value: (Character.BRAM,), + ShopUpgrade.BRAM_WHIPLASH.value: (Character.BRAM,), +} + +VANILLA_CHARACTERS: "frozenset[Character]" = frozenset((Character.ALGUS, Character.ARIAS, Character.KYULI)) + +characters_off = [rule_builder.OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)] +characters_on = [rule_builder.OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")] + + +def _printjson_item(item: str, player: int, state: "CollectionState | None" = None) -> "JSONMessagePart": + message: JSONMessagePart = {"type": "item_name", "flags": 0b001, "text": item, "player": player} + if state: + color = "green" if state.has(item, player) else "salmon" + if item == Events.FAKE_OOL_ITEM: + color = "glitched" + message["color"] = color + return message + + +@dataclasses.dataclass() +class Has(rule_builder.Rule): + item_name: "ItemName | Events" + count: int = 1 + + @override + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + default = self.Resolved(self.item_name.value, self.count, player=world.player) + + if self.item_name in VANILLA_CHARACTERS: + if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: + return rule_builder.True_.Resolved(player=world.player) + return default + + if deps := ITEM_DEPS.get(self.item_name): + if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla and ( + len(deps) > 1 or (len(deps) == 1 and deps[0] in VANILLA_CHARACTERS) + ): + return default + if len(deps) == 1: + return HasAll.Resolved((deps[0].value, self.item_name.value), player=world.player) + return rule_builder.Or.Resolved( + tuple(HasAll.Resolved((d.value, self.item_name.value), player=world.player) for d in deps), + player=world.player, + ) + + return default + + @override + def __str__(self) -> str: + count = f", count={self.count}" if self.count > 1 else "" + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.item_name.value}{count}{options})" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.Has.Resolved): + @override + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages = super().explain(state) + messages[-1] = _printjson_item(self.item_name, self.player, state) + return messages + + +@dataclasses.dataclass() +class HasAll(rule_builder.Rule): + item_names: "tuple[ItemName | Events, ...]" + + def __init__( + self, + *item_names: "ItemName | Events", + options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + ) -> None: + if len(item_names) != len(set(item_names)): + raise ValueError(f"Duplicate items detected, likely typo, items: {item_names}") + + super().__init__(options=options) + self.item_names = item_names + + @override + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + if len(self.item_names) == 0: + return rule_builder.True_.Resolved(player=world.player) + if len(self.item_names) == 1: + return Has(self.item_names[0]).resolve(world) + + new_clauses: list[rule_builder.Rule.Resolved] = [] + new_items: list[str] = [] + for item in self.item_names: + if ( + item in VANILLA_CHARACTERS + and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla + ): + continue + deps = ITEM_DEPS.get(item, []) + if not deps: + new_items.append(item.value) + continue + + if len(deps) > 1: + if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: + new_items.append(item.value) + else: + new_clauses.append( + rule_builder.Or.Resolved( + tuple(HasAll.Resolved((d.value, item.value), player=world.player) for d in deps), + player=world.player, + ) + ) + continue + + if ( + len(deps) == 1 + and deps[0] not in self.item_names + and not ( + deps[0] in VANILLA_CHARACTERS + and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla + ) + ): + new_items.append(deps[0].value) + + new_items.append(item.value) + + if len(new_clauses) == 0 and len(new_items) == 0: + return rule_builder.True_.Resolved(player=world.player) + if len(new_items) == 1: + new_clauses.append(Has.Resolved(new_items[0], player=world.player)) + elif len(new_items) > 1: + new_clauses.append(HasAll.Resolved(tuple(new_items), player=world.player)) + if len(new_clauses) == 0: + return rule_builder.False_.Resolved(player=world.player) + if len(new_clauses) == 1: + return new_clauses[0] + return rule_builder.And.Resolved(tuple(new_clauses), player=world.player) + + @override + def __str__(self) -> str: + items = ", ".join([i.value for i in self.item_names]) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.HasAll.Resolved): + @override + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append(_printjson_item(item, self.player, state)) + messages.append({"type": "text", "text": ")"}) + return messages + + +@dataclasses.dataclass() +class HasAny(rule_builder.Rule): + item_names: "tuple[ItemName | Events, ...]" + + def __init__( + self, + *item_names: "ItemName | Events", + options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + ) -> None: + if len(item_names) != len(set(item_names)): + raise ValueError(f"Duplicate items detected, likely typo, items: {item_names}") + + super().__init__(options=options) + self.item_names = item_names + + @override + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + if len(self.item_names) == 0: + return rule_builder.True_.Resolved(player=world.player) + if len(self.item_names) == 1: + return Has(self.item_names[0]).resolve(world) + + new_clauses: list[rule_builder.Rule.Resolved] = [] + new_items: list[str] = [] + for item in self.item_names: + if ( + item in VANILLA_CHARACTERS + and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla + ): + return rule_builder.True_.Resolved(player=world.player) + + deps = ITEM_DEPS.get(item, []) + if not deps: + new_items.append(item.value) + continue + + if len(deps) > 1: + if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: + new_items.append(item.value) + else: + new_clauses.append( + rule_builder.Or.Resolved( + tuple(HasAll.Resolved((d.value, item.value), player=world.player) for d in deps), + player=world.player, + ) + ) + continue + + if ( + len(deps) == 1 + and deps[0] not in self.item_names + and not ( + deps[0] in VANILLA_CHARACTERS + and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla + ) + ): + new_clauses.append(HasAll.Resolved((deps[0].value, item.value), player=world.player)) + else: + new_items.append(item.value) + + if len(new_items) == 1: + new_clauses.append(Has.Resolved(new_items[0], player=world.player)) + elif len(new_items) > 1: + new_clauses.append(HasAny.Resolved(tuple(new_items), player=world.player)) + + if len(new_clauses) == 0: + return rule_builder.False_.Resolved(player=world.player) + if len(new_clauses) == 1: + return new_clauses[0] + return rule_builder.Or.Resolved(tuple(new_clauses), player=world.player) + + @override + def __str__(self) -> str: + items = ", ".join([i.value for i in self.item_names]) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.HasAny.Resolved): + @override + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "any"}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append(_printjson_item(item, self.player, state)) + messages.append({"type": "text", "text": ")"}) + return messages + + +@dataclasses.dataclass() +class CanReachLocation(rule_builder.Rule): + location_name: "LocationName" + + @override + def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + location = world.get_location(self.location_name) + if not location.parent_region: + raise ValueError(f"Location {location.name} has no parent region") + parent_region_name = location.parent_region.name + return self.Resolved(self.location_name.value, parent_region_name, player=world.player) + + @override + def __str__(self) -> str: + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.location_name.value}{options})" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.CanReachLocation.Resolved): + pass + + +@dataclasses.dataclass() +class CanReachRegion(rule_builder.Rule): + region_name: "RegionName" + + @override + def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + return self.Resolved(self.region_name.value, player=world.player) + + @override + def __str__(self) -> str: + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.region_name.value}{options})" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.CanReachRegion.Resolved): + pass + + +@dataclasses.dataclass() +class CanReachEntrance(rule_builder.Rule): + from_region: "RegionName" + to_region: "RegionName" + + @override + def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + entrance = f"{self.from_region.value} -> {self.to_region.value}" + return self.Resolved(entrance, player=world.player) + + @override + def __str__(self) -> str: + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.from_region.value} -> {self.to_region.value}{options})" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.CanReachEntrance.Resolved): + pass + + +@dataclasses.dataclass(init=False) +class ToggleRule(HasAll): + option_cls: "ClassVar[type[Option[int]]]" + otherwise: bool = False + + @override + def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + if len(self.item_names) == 1: + rule = Has(self.item_names[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) + else: + rule = HasAll(*self.item_names, options=[rule_builder.OptionFilter(self.option_cls, 1)]) + + if self.otherwise: + return rule_builder.Or( + rule, + rule_builder.True_(options=[rule_builder.OptionFilter(self.option_cls, 0)]), + ).resolve(world) + + return rule.resolve(world) + + +@dataclasses.dataclass(init=False) +class HasWhite(ToggleRule): + option_cls = RandomizeWhiteKeys + + def __init__( + self, + *doors: "WhiteDoor", + otherwise: bool = False, + options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + ) -> None: + super().__init__(*doors, options=options) + self.otherwise = otherwise + + +@dataclasses.dataclass(init=False) +class HasBlue(ToggleRule): + option_cls = RandomizeBlueKeys + + def __init__( + self, + *doors: "BlueDoor", + otherwise: bool = False, + options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + ) -> None: + super().__init__(*doors, options=options) + self.otherwise = otherwise + + +@dataclasses.dataclass(init=False) +class HasRed(ToggleRule): + option_cls = RandomizeRedKeys + + def __init__( + self, + *doors: "RedDoor", + otherwise: bool = False, + options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + ) -> None: + super().__init__(*doors, options=options) + self.otherwise = otherwise + + +@dataclasses.dataclass(init=False) +class HasSwitch(ToggleRule): + option_cls = RandomizeSwitches + + def __init__( + self, + *switches: "Switch | Crystal | Face", + otherwise: bool = False, + options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + ) -> None: + super().__init__(*switches, options=options) + self.otherwise = otherwise + + +@dataclasses.dataclass(init=False) +class HasElevator(HasAll): + def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.OptionFilter[Any]]" = ()) -> None: + super().__init__( + KeyItem.ASCENDANT_KEY, + elevator, + options=[*options, rule_builder.OptionFilter(RandomizeElevator, RandomizeElevator.option_true)], + ) + + +@dataclasses.dataclass() +class HasGoal(rule_builder.Rule): + @override + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + if world.options.goal.value != Goal.option_eye_hunt: + return rule_builder.True_.Resolved(player=world.player) + return Has.Resolved( + Eye.GOLD.value, + count=world.options.additional_eyes_required.value, + player=world.player, + ) + + +@dataclasses.dataclass() +class HardLogic(rule_builder.Rule): + child: "rule_builder.Rule" + + @override + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + if world.options.difficulty.value == Difficulty.option_hard: + return self.child.resolve(world) + if getattr(world.multiworld, "generation_is_fake", False): + return self.Resolved(self.child.resolve(world), player=world.player) + return rule_builder.False_.Resolved(player=world.player) + + @override + def __str__(self) -> str: + return f"HardLogic[{self.child!s}]" + + @dataclasses.dataclass(frozen=True) + class Resolved(rule_builder.Rule.Resolved): + child: "rule_builder.Rule.Resolved" + + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child.test(state) + + @override + def item_dependencies(self) -> dict[str, set[int]]: + deps = self.child.item_dependencies() + deps.setdefault(Events.FAKE_OOL_ITEM.value, set()).add(id(self)) + return deps + + @override + def indirect_regions(self) -> tuple[str, ...]: + return self.child.indirect_regions() + + @override + def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: "list[JSONMessagePart]" = [ + {"type": "color", "color": "glitched", "text": "Hard Logic ["}, + ] + messages.extend(self.child.explain(state)) + messages.append({"type": "color", "color": "glitched", "text": "]"}) + return messages diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index a914f3e22588..3e121cb83428 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1,3 +1,5 @@ +from rule_builder import And, OptionFilter, Or, Rule, True_ + from ..items import ( BlueDoor, Character, @@ -12,12 +14,19 @@ WhiteDoor, ) from ..locations import LocationName as L +from ..options import ( + ApexElevator, + Difficulty, + RandomizeBlueKeys, + RandomizeCharacters, + RandomizeRedKeys, + RandomizeSwitches, + RandomizeWhiteKeys, +) from ..regions import RegionName as R -from .factories import ( - And, +from .custom_rules import ( CanReachEntrance, CanReachRegion, - False_, HardLogic, Has, HasAll, @@ -28,47 +37,36 @@ HasRed, HasSwitch, HasWhite, - Or, - RuleFactory, - True_, ) -easy = {"difficulty": 0} -characters_off = {"randomize_characters": 0} -characters_on = {"randomize_characters__ge": 1} -white_off = {"randomize_white_keys": 0} -blue_off = {"randomize_blue_keys": 0} -red_off = {"randomize_red_keys": 0} -switch_off = {"randomize_switches": 0} - -true = True_() -false = False_() +easy = [OptionFilter(Difficulty, Difficulty.option_easy)] +characters_off = [OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)] +characters_on = [OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")] +white_off = [OptionFilter(RandomizeWhiteKeys, RandomizeWhiteKeys.option_false)] +blue_off = [OptionFilter(RandomizeBlueKeys, RandomizeBlueKeys.option_false)] +red_off = [OptionFilter(RandomizeRedKeys, RandomizeRedKeys.option_false)] +switch_off = [OptionFilter(RandomizeSwitches, RandomizeSwitches.option_false)] -can_uppies = HardLogic( - Or( - True_(options=characters_off), - HasAny(Character.ARIAS, Character.BRAM, options=characters_on), - ) -) -can_extra_height = Or(HasAny(Character.KYULI, KeyItem.BLOCK), can_uppies) -can_extra_height_gold_block = Or(HasAny(Character.KYULI, Character.ZEEK), can_uppies) -can_combo_height = And(can_uppies, HasAll(KeyItem.BELL, KeyItem.BLOCK)) +can_uppies = HardLogic(True_(options=characters_off) | HasAny(Character.ARIAS, Character.BRAM, options=characters_on)) +can_extra_height = HasAny(Character.KYULI, KeyItem.BLOCK) | can_uppies +can_extra_height_gold_block = HasAny(Character.KYULI, Character.ZEEK) | can_uppies +can_combo_height = can_uppies & HasAll(KeyItem.BELL, KeyItem.BLOCK) can_block_in_wall = HardLogic(HasAll(Character.ZEEK, KeyItem.BLOCK)) -can_crystal = Or( - HasAny(Character.ALGUS, KeyItem.BLOCK, ShopUpgrade.BRAM_WHIPLASH), - HasAll(Character.ZEEK, KeyItem.BANISH), - HardLogic(Has(ShopUpgrade.KYULI_RAY)), +can_crystal = ( + HasAny(Character.ALGUS, KeyItem.BLOCK, ShopUpgrade.BRAM_WHIPLASH) + | HasAll(Character.ZEEK, KeyItem.BANISH) + | HardLogic(Has(ShopUpgrade.KYULI_RAY)) ) -can_crystal_wo_whiplash = Or( - HasAny(Character.ALGUS, KeyItem.BLOCK), - HasAll(Character.ZEEK, KeyItem.BANISH), - HardLogic(Has(ShopUpgrade.KYULI_RAY)), +can_crystal_wo_whiplash = ( + HasAny(Character.ALGUS, KeyItem.BLOCK) + | HasAll(Character.ZEEK, KeyItem.BANISH) + | HardLogic(Has(ShopUpgrade.KYULI_RAY)) ) can_big_magic = HardLogic(HasAll(Character.ALGUS, KeyItem.BANISH, ShopUpgrade.ALGUS_ARCANIST)) -can_kill_ghosts = Or( - HasAny(KeyItem.BANISH, KeyItem.BLOCK), - HasAll(ShopUpgrade.ALGUS_METEOR, KeyItem.CHALICE, options=easy), - HardLogic(Has(ShopUpgrade.ALGUS_METEOR)), +can_kill_ghosts = ( + HasAny(KeyItem.BANISH, KeyItem.BLOCK) + | HasAll(ShopUpgrade.ALGUS_METEOR, KeyItem.CHALICE, options=easy) + | HardLogic(Has(ShopUpgrade.ALGUS_METEOR)) ) otherwise_crystal = Or( @@ -78,18 +76,21 @@ options=switch_off, ) otherwise_bow = Has(KeyItem.BOW, options=switch_off) -chalice_on_easy = Or(HardLogic(True_()), Has(KeyItem.CHALICE, options=easy)) +chalice_on_easy = HardLogic(True_()) | Has(KeyItem.CHALICE, options=easy) -elevator_apex = Or( - HasElevator(Elevator.APEX, options={"apex_elevator": 1}), - Has(KeyItem.ASCENDANT_KEY, options={"apex_elevator": 0}), +elevator_apex = HasElevator( + Elevator.APEX, + options=[OptionFilter(ApexElevator, ApexElevator.option_included)], +) | Has( + KeyItem.ASCENDANT_KEY, + options=[OptionFilter(ApexElevator, ApexElevator.option_vanilla)], ) # TODO: better implementations shop_cheap = CanReachRegion(R.GT_LEFT) shop_moderate = CanReachRegion(R.MECH_START) shop_expensive = CanReachRegion(R.ROA_START) -MAIN_ENTRANCE_RULES: dict[tuple[R, R], RuleFactory] = { +MAIN_ENTRANCE_RULES: dict[tuple[R, R], Rule] = { (R.SHOP, R.SHOP_ALGUS): Has(Character.ALGUS), (R.SHOP, R.SHOP_ARIAS): Has(Character.ARIAS), (R.SHOP, R.SHOP_KYULI): Has(Character.KYULI), @@ -1027,7 +1028,7 @@ (R.SP_STAR_END, R.SP_STAR_CONNECTION): And(Has(KeyItem.STAR), HasSwitch(Switch.SP_AFTER_STAR)), } -MAIN_LOCATION_RULES: dict[L, RuleFactory] = { +MAIN_LOCATION_RULES: dict[L, Rule] = { L.GT_GORGONHEART: Or( HasSwitch(Switch.GT_GH, otherwise=True), HasAny(Character.KYULI, KeyItem.ICARUS, KeyItem.BLOCK, KeyItem.CLOAK, KeyItem.BOOTS), diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index dd720843e51b..86249563eefd 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -1,10 +1,10 @@ import logging -from collections import defaultdict from functools import cached_property from typing import TYPE_CHECKING, Any, ClassVar, Final -from BaseClasses import CollectionState, Item, ItemClassification, Region, Tutorial +from BaseClasses import Item, ItemClassification, Region, Tutorial from Options import OptionError +from rule_builder import RuleWorldMixin from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import Component, Type, components, icon_paths, launch_subprocess @@ -47,8 +47,6 @@ from BaseClasses import Location, MultiWorld from Options import Option - from .logic import RuleInstance - # ██░░░██████░░███░░░███ # ██░░░░██░░░▓▓░░░▓░░███ @@ -91,9 +89,7 @@ def launch_client() -> None: launch_subprocess(launch, name="Astalon Tracker") -components.append( - Component("Astalon Tracker", func=launch_client, component_type=Type.CLIENT, icon="astalon") -) +components.append(Component("Astalon Tracker", func=launch_client, component_type=Type.CLIENT, icon="astalon")) icon_paths["astalon"] = f"ap:{__name__}/images/pil.png" @@ -112,7 +108,7 @@ class AstalonWebWorld(WebWorld): ] -class AstalonWorld(World): +class AstalonWorld(RuleWorldMixin, World): """ Uphold your pact with the Titan of Death, Epimetheus! Fight, climb and solve your way through a twisted tower as three unique adventurers, @@ -139,13 +135,8 @@ class AstalonWorld(World): ut_can_gen_without_yaml = True glitches_item_name = Events.FAKE_OOL_ITEM.value - rule_cache: "dict[int, RuleInstance]" - _rule_deps: "dict[str, set[int]]" - def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) - self.rule_cache = {} - self._rule_deps = defaultdict(set) self.starting_characters = [] def generate_early(self) -> None: @@ -161,9 +152,7 @@ def generate_early(self) -> None: self.starting_characters.extend(CHARACTER_STARTS[int(self.options.randomize_characters)]) if self.options.goal == Goal.option_eye_hunt: - self.extra_gold_eyes = round( - self.options.additional_eyes_required.value * (self.options.extra_eyes / 100) - ) + self.extra_gold_eyes = round(self.options.additional_eyes_required.value * (self.options.extra_eyes / 100)) re_gen_passthrough = getattr(self.multiworld, "re_gen_passthrough", {}) if re_gen_passthrough and GAME_NAME in re_gen_passthrough: @@ -171,7 +160,7 @@ def generate_early(self) -> None: slot_options: dict[str, Any] = slot_data.get("options", {}) for key, value in slot_options.items(): - opt: Option | None = getattr(self.options, key, None) + opt: Option[Any] | None = getattr(self.options, key, None) if opt is not None: setattr(self.options, key, opt.from_any(value)) @@ -187,12 +176,7 @@ def create_location(self, name: str) -> AstalonLocation: location = AstalonLocation(self.player, name, location_name_to_id.get(name), region) rule = MAIN_LOCATION_RULES.get(location_name) if rule is not None: - rule = rule.resolve(self) - if rule.always_false: - logger.debug(f"No matching rules for {name}") - for item_name, rules in rule.deps().items(): - self._rule_deps[item_name] |= rules - location.access_rule = rule.test + self.set_rule(location, rule) region.locations.append(location) return location @@ -206,23 +190,19 @@ def create_regions(self) -> None: for exit_region_name in region_data.exits: region_pair = (region_name, exit_region_name) rule = MAIN_ENTRANCE_RULES.get(region_pair) + resolved_rule = None if rule is not None: - rule = rule.resolve(self) - if rule.always_false: + resolved_rule = self.resolve_rule(rule) + if resolved_rule.always_false: logger.debug(f"No matching rules for {region_name.value} -> {exit_region_name.value}") continue - for item_name, rules in rule.deps().items(): - self._rule_deps[item_name] |= rules entrance = region.connect( - self.get_region(exit_region_name.value), rule=rule.test if rule else None + self.get_region(exit_region_name.value), + rule=resolved_rule.test if resolved_rule else None, ) - if rule: - for indirect_region in rule.indirect(): - self.multiworld.register_indirect_condition( - self.get_region(indirect_region.value), - entrance, - ) + if resolved_rule: + self.register_rule_connections(resolved_rule, entrance) logic_groups: set[str] = set() if self.options.randomize_key_items: @@ -524,15 +504,9 @@ def _get_character_strengths(self) -> dict[str, float]: for sphere_id, sphere in enumerate(spheres): for location in sphere: - if ( - location.item - and location.item.player == self.player - and location.item.name in character_strengths - ): + if location.item and location.item.player == self.player and location.item.name in character_strengths: scaling = (sphere_id + 1) / sphere_count - logger.debug( - f"{location.item.name} in sphere {sphere_id + 1} / {sphere_count}, scaling {scaling}" - ) + logger.debug(f"{location.item.name} in sphere {sphere_id + 1} / {sphere_count}, scaling {scaling}") character_strengths[location.item.name] = scaling found += 1 if found >= limit: @@ -540,19 +514,3 @@ def _get_character_strengths(self) -> dict[str, float]: logger.warning("Could not find all Astalon characters in spheres, something is likely wrong") return character_strengths - - def collect(self, state: "CollectionState", item: "Item") -> bool: - changed = super().collect(state, item) - if changed and getattr(self, "_rule_deps", None): - player_results: dict[int, bool] = state._astalon_rule_results[self.player] # type: ignore - for rule_id in self._rule_deps[item.name]: - player_results.pop(rule_id, None) - return changed - - def remove(self, state: "CollectionState", item: "Item") -> bool: - changed = super().remove(state, item) - if changed and getattr(self, "_rule_deps", None): - player_results: dict[int, bool] = state._astalon_rule_results[self.player] # type: ignore - for rule_id in self._rule_deps[item.name]: - player_results.pop(rule_id, None) - return changed From e749b4d0c16aabfa089199b0b74482e5b035b638 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 15 Jun 2025 15:21:06 -0400 Subject: [PATCH 017/135] update tests --- worlds/astalon/test/test_rules.py | 40 ++++++++++++++++++------------- worlds/astalon/world.py | 3 +++ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py index 08fef9b3dcf1..e33a6ccd7ac2 100644 --- a/worlds/astalon/test/test_rules.py +++ b/worlds/astalon/test/test_rules.py @@ -1,6 +1,8 @@ +from rule_builder import And, OptionFilter, Or, True_ + from ..items import BlueDoor, Character, Crystal, KeyItem -from ..logic.factories import And, Has, HasAny, HasBlue, HasSwitch, Or, True_ -from ..logic.instances import HasAllInstance, HasInstance, OrInstance +from ..logic.custom_rules import Has, HasAll, HasAny, HasBlue, HasSwitch +from ..options import Difficulty, RandomizeCharacters from .bases import AstalonTestBase @@ -12,17 +14,17 @@ def run_default_tests(self) -> bool: return False def test_same_rules_have_same_hash(self) -> None: - rule1 = HasInstance("Item", player=1) - rule2 = HasInstance("Item", player=1) + rule1 = Has.Resolved("Item", player=1) + rule2 = Has.Resolved("Item", player=1) self.assertEqual(hash(rule1), hash(rule2)) def test_different_rules_have_different_hashes(self) -> None: - rule1 = HasInstance("Item", player=1) - rule2 = HasInstance("Item", player=2) + rule1 = Has.Resolved("Item", player=1) + rule2 = Has.Resolved("Item", player=2) self.assertNotEqual(hash(rule1), hash(rule2)) - rule3 = HasInstance("Item1", player=1) - rule4 = HasInstance("Item2", player=1) + rule3 = Has.Resolved("Item1", player=1) + rule4 = Has.Resolved("Item2", player=1) self.assertNotEqual(hash(rule3), hash(rule4)) @@ -49,23 +51,27 @@ def test_upper_path_rule_easy(self) -> None: rule = Or( HasSwitch(Crystal.GT_ROTA), Or( - True_(options={"randomize_characters": 0}), - HasAny(Character.ARIAS, Character.BRAM, options={"randomize_characters__ge": 1}), - options={"difficulty": 1}, + True_(options=[OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)]), + HasAny( + Character.ARIAS, + Character.BRAM, + options=[OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")], + ), + options=[OptionFilter(Difficulty, Difficulty.option_hard)], ), And(Has(KeyItem.STAR), HasBlue(BlueDoor.GT_RING, otherwise=True)), Has(KeyItem.BLOCK), ) - expected = OrInstance( + expected = Or.Resolved( ( - HasAllInstance( - ("Blue Door (Gorgon Tomb - Ring of the Ancients)", "Bram", "Morning Star"), + HasAll.Resolved( + ("Bram", "Morning Star", "Blue Door (Gorgon Tomb - Ring of the Ancients)"), player=self.player, ), - HasAllInstance(("Zeek", "Magic Block"), player=self.player), - HasInstance("Crystal (Gorgon Tomb - RotA)", player=self.player), + HasAll.Resolved(("Zeek", "Magic Block"), player=self.player), + Has.Resolved("Crystal (Gorgon Tomb - RotA)", player=self.player), ), player=self.player, ) instance = rule.resolve(self.world) - self.assertEqual(instance, expected) + self.assertEqual(instance, expected, f"\n{instance}\n{expected}") diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 86249563eefd..c06e4e39811d 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -108,6 +108,9 @@ class AstalonWebWorld(WebWorld): ] +# TODO: Wrap rule, connect helper, better world typing (generic) + + class AstalonWorld(RuleWorldMixin, World): """ Uphold your pact with the Titan of Death, Epimetheus! From e646a72a1bb2ec3c12195313c2b07ba9c28ce389 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 15 Jun 2025 22:33:02 -0400 Subject: [PATCH 018/135] update typing and add decorator --- docs/rule builder.md | 88 ++++++++--------- rule_builder.py | 219 ++++++++++++++++++++++++++----------------- 2 files changed, 171 insertions(+), 136 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index d372312b0869..ee9354c83b0d 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -20,8 +20,8 @@ The rule builder comes with a few by default: - `True_`: Always returns true - `False_`: Always returns false -- `And`: Checks that all child rules are true (provided by `&` operator) -- `Or`: Checks that at least one child rule is true (provided by `|` operator) +- `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 @@ -35,7 +35,7 @@ You can combine these rules together to describe the logic required for somethin 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. 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`. When assigning the rule you must use the `set_rule` helper added by the rule mixin to correctly resolve and register the rule. @@ -89,14 +89,14 @@ rule = Or( ## 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 a `Resolved` child class that defines an `_evaluate` method. You may need to also define an `item_dependencies` or `indirect_regions` function. +You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules and putting the `custom_rule` decorator on it. You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. You may need to also define an `item_dependencies` or `indirect_regions` function. To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement: ```python -@dataclasses.dataclass() -class CanGoal(Rule): - def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": +@custom_rule(MyWorld) +class CanGoal(Rule[MyWorld]): + def _instantiate(self, world: "MyWorld") -> "Resolved": return self.Resolved(world.required_mcguffins, player=world.player) @dataclasses.dataclass(frozen=True) @@ -110,16 +110,42 @@ class CanGoal(Rule): return {"McGuffin": {id(self)}} ``` -If you want to use the serialization, you must add a `custom_rule_classes` class var to your world that points to the custom rules you've defined. +### Item dependencies + +If 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. ```python -class MyWorld(RuleWorldMixin, World): - game = "My Game" - custom_rule_classes = { - "CanGoal": CanGoal, - } +@custom_rule(MyWorld) +class MyRule(Rule[MyWorld]): + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + item_name: str + + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {self.item_name: {id(self)}} +``` + +The default `Has`, `HasAll`, and `HasAny` rules define this function already. + +### Indirect connections + +If your custom rule references other regions, it must define an `indirect_regions` function that returns a tuple of region names. These will be collected and indirect connections will be registered when you set this rule on an entrance. + +```python +@custom_rule(MyWorld) +class MyRule(Rule[MyWorld]): + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + region_name: str + + @override + def indirect_regions(self) -> tuple[str, ...]: + return (self.region_name,) ``` +The default `CanReachLocation` and `CanReachRegion` rules define this function already. + ## JSON serialization The rule builder is intended to be written first in Python for optimization and type safety. To export the rules to a client or tracker, there is a default JSON serializer implementation for all rules. By default the rules will export with the following format: @@ -187,39 +213,3 @@ class MyRule(Rule): {"type": "text", "text": " tall to beat the game"}, ] ``` - -## Item dependencies - -If 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. - -```python -@dataclasses.dataclass() -class MyRule(Rule): - @dataclasses.dataclass(frozen=True) - class Resolved(Rule.Resolved): - item_name: str - - @override - def item_dependencies(self) -> dict[str, set[int]]: - return {self.item_name: {id(self)}} -``` - -The default `Has`, `HasAll`, and `HasAny` rules define this function already. - -## Indirect connections - -If your custom rule references other regions, it must define an `indirect_regions` function that returns a tuple of region names. These will be collected and indirect connections will be registered when you set this rule on an entrance. - -```python -@dataclasses.dataclass() -class MyRule(Rule): - @dataclasses.dataclass(frozen=True) - class Resolved(Rule.Resolved): - region_name: str - - @override - def indirect_regions(self) -> tuple[str, ...]: - return (self.region_name,) -``` - -The default `CanReachLocation` and `CanReachRegion` rules define this function already. diff --git a/rule_builder.py b/rule_builder.py index 88fdc7174222..922474f44a8b 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -3,20 +3,112 @@ import operator from collections import defaultdict from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, TypeVar, cast, dataclass_transform from typing_extensions import Never, Self, override from BaseClasses import Entrance if TYPE_CHECKING: - from BaseClasses import CollectionState, Item, Location, MultiWorld + from BaseClasses import CollectionState, Item, Location, MultiWorld, Region from NetUtils import JSONMessagePart from Options import CommonOptions, Option from worlds.AutoWorld import World else: World = object + +class RuleWorldMixin(World): + rule_ids: "dict[int, Rule.Resolved]" + rule_dependencies: dict[str, set[int]] + + custom_rule_classes: "ClassVar[dict[str, type[Rule[Self]]]]" + + def __init__(self, multiworld: "MultiWorld", player: int) -> None: + super().__init__(multiworld, player) + self.rule_ids = {} + self.rule_dependencies = defaultdict(set) + + @classmethod + def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": + custom_rule_classes = getattr(cls, "custom_rule_classes", {}) + if name not in DEFAULT_RULES and name not in custom_rule_classes: + raise ValueError(f"Rule {name} not found") + return custom_rule_classes.get(name) or DEFAULT_RULES[name] + + @classmethod + def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule[Self]": + name = data.get("rule", "") + rule_class = cls.get_rule_cls(name) + return rule_class.from_json(data) + + def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": + resolved_rule = rule.resolve(self) + if resolved_rule.cacheable: + for item_name, rule_ids in resolved_rule.item_dependencies().items(): + self.rule_dependencies[item_name] |= rule_ids + return resolved_rule + + def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: + for indirect_region in resolved_rule.indirect_regions(): + self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) + + def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: + resolved_rule = self.resolve_rule(rule) + spot.access_rule = resolved_rule.test + if self.explicit_indirect_conditions and isinstance(spot, Entrance): + self.register_rule_connections(resolved_rule, spot) + + def create_entrance( + self, + from_region: "Region", + to_region: "Region", + rule: "Rule[Self] | None", + ) -> "Entrance | None": + resolved_rule = None + if rule is not None: + resolved_rule = self.resolve_rule(rule) + if resolved_rule.always_false: + return None + + entrance = from_region.connect(to_region, rule=resolved_rule.test if resolved_rule else None) + if resolved_rule is not None: + self.register_rule_connections(resolved_rule, entrance) + return entrance + + @override + def collect(self, state: "CollectionState", item: "Item") -> bool: + changed = super().collect(state, item) + if changed and getattr(self, "rule_dependencies", None): + player_results: dict[int, bool] = state.rule_cache[self.player] + for rule_id in self.rule_dependencies[item.name]: + _ = player_results.pop(rule_id, None) + return changed + + @override + def remove(self, state: "CollectionState", item: "Item") -> bool: + changed = super().remove(state, item) + if changed and getattr(self, "rule_dependencies", None): + player_results: dict[int, bool] = state.rule_cache[self.player] + for rule_id in self.rule_dependencies[item.name]: + _ = player_results.pop(rule_id, None) + return changed + + +TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True) # noqa: PLC0105 + + +@dataclass_transform() +def custom_rule(world_cls: "type[TWorld]", init: bool = True) -> "Callable[..., type[Rule[TWorld]]]": + def decorator(rule_cls: "type[Rule[TWorld]]") -> "type[Rule[TWorld]]": + if not hasattr(world_cls, "custom_rule_classes"): + world_cls.custom_rule_classes = {} + world_cls.custom_rule_classes[rule_cls.__name__] = rule_cls + return dataclasses.dataclass(init=init)(rule_cls) + + return decorator + + Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] OPERATORS = { @@ -40,7 +132,7 @@ class OptionFilter(Generic[T]): @dataclasses.dataclass() -class Rule: +class Rule(Generic[TWorld]): """Base class for a static rule used to generate an access rule""" options: "Iterable[OptionFilter[Any]]" = dataclasses.field(default=(), kw_only=True) @@ -64,11 +156,11 @@ def _passes_options(self, options: "CommonOptions") -> bool: return True - def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + def _instantiate(self, world: "TWorld") -> "Resolved": """Create a new resolved rule for this world""" return self.Resolved(player=world.player) - def resolve(self, world: "RuleWorldMixin") -> "Resolved": + def resolve(self, world: "TWorld") -> "Resolved": """Resolve a rule with the given world""" if not self._passes_options(world.options): return False_.Resolved(player=world.player) @@ -94,7 +186,7 @@ def to_json(self) -> Mapping[str, Any]: def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(**data.get("args", {})) - def __and__(self, other: "Rule") -> "Rule": + def __and__(self, other: "Rule[TWorld]") -> "Rule[TWorld]": """Combines two rules into an And rule""" if isinstance(self, And): if isinstance(other, And): @@ -106,7 +198,7 @@ def __and__(self, other: "Rule") -> "Rule": return And(self, *other.children, options=other.options) return And(self, other) - def __or__(self, other: "Rule") -> "Rule": + def __or__(self, other: "Rule[TWorld]") -> "Rule[TWorld]": """Combines two rules into an Or rule""" if isinstance(self, Or): if isinstance(other, Or): @@ -119,6 +211,7 @@ def __or__(self, other: "Rule") -> "Rule": return Or(self, other) def __bool__(self) -> Never: + """Safeguard to prevent devs from mistakenly doing `rule1 and rule2` and getting the wrong result""" raise TypeError("Use & or | to combine rules, or use `is not None` for boolean tests") @override @@ -144,7 +237,13 @@ class Resolved: @override def __hash__(self) -> int: - return hash((self.__class__.__name__, *[getattr(self, f.name) for f in dataclasses.fields(self)])) + return hash( + ( + self.__class__.__module__, + self.__class__.__name__, + *[getattr(self, f.name) for f in dataclasses.fields(self)], + ) + ) def _evaluate(self, state: "CollectionState") -> bool: """Calculate this rule's result with the given state""" @@ -180,7 +279,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() -class True_(Rule): +class True_(Rule[TWorld]): """A rule that always returns True""" @dataclasses.dataclass(frozen=True) @@ -198,7 +297,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() -class False_(Rule): +class False_(Rule[TWorld]): """A rule that always returns False""" @dataclasses.dataclass(frozen=True) @@ -216,15 +315,15 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass(init=False) -class NestedRule(Rule): - children: "tuple[Rule, ...]" +class NestedRule(Rule[TWorld]): + children: "tuple[Rule[TWorld], ...]" - def __init__(self, *children: "Rule", options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *children: "Rule[TWorld]", options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) self.children = children @override - def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": children = [c.resolve(world) for c in self.children] return self.Resolved(tuple(children), player=world.player).simplify() @@ -271,7 +370,7 @@ def simplify(self) -> "Rule.Resolved": @dataclasses.dataclass(init=False) -class And(NestedRule): +class And(NestedRule[TWorld]): @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): @override @@ -346,7 +445,7 @@ def simplify(self) -> "Rule.Resolved": @dataclasses.dataclass(init=False) -class Or(NestedRule): +class Or(NestedRule[TWorld]): @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): @override @@ -418,12 +517,12 @@ def simplify(self) -> "Rule.Resolved": @dataclasses.dataclass() -class Has(Rule): +class Has(Rule[TWorld]): item_name: str count: int = 1 @override - def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + def _instantiate(self, world: "TWorld") -> "Resolved": return self.Resolved(self.item_name, self.count, player=world.player) @override @@ -456,7 +555,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass(init=False) -class HasAll(Rule): +class HasAll(Rule[TWorld]): """A rule that checks if the player has all of the given items""" item_names: tuple[str, ...] @@ -467,11 +566,11 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () self.item_names = tuple(sorted(set(item_names))) @override - def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: - return True_().resolve(world) + return True_[TWorld]().resolve(world) if len(self.item_names) == 1: - return Has(self.item_names[0]).resolve(world) + return Has[TWorld](self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @override @@ -508,7 +607,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() -class HasAny(Rule): +class HasAny(Rule[TWorld]): """A rule that checks if the player has at least one of the given items""" item_names: tuple[str, ...] @@ -519,11 +618,11 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () self.item_names = tuple(sorted(set(item_names))) @override - def _instantiate(self, world: "RuleWorldMixin") -> "Rule.Resolved": + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: - return True_().resolve(world) + return True_[TWorld]().resolve(world) if len(self.item_names) == 1: - return Has(self.item_names[0]).resolve(world) + return Has[TWorld](self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @override @@ -560,7 +659,7 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() -class CanReachLocation(Rule): +class CanReachLocation(Rule[TWorld]): location_name: str """The name of the location to test access to""" @@ -573,7 +672,7 @@ class CanReachLocation(Rule): """ @override - def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + def _instantiate(self, world: "TWorld") -> "Resolved": parent_region_name = self.parent_region_name if not parent_region_name and not self.skip_indirect_connection: location = world.get_location(self.location_name) @@ -612,11 +711,11 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() -class CanReachRegion(Rule): +class CanReachRegion(Rule[TWorld]): region_name: str @override - def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + def _instantiate(self, world: "TWorld") -> "Resolved": return self.Resolved(self.region_name, player=world.player) @override @@ -646,11 +745,11 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa @dataclasses.dataclass() -class CanReachEntrance(Rule): +class CanReachEntrance(Rule[TWorld]): entrance_name: str @override - def _instantiate(self, world: "RuleWorldMixin") -> "Resolved": + def _instantiate(self, world: "TWorld") -> "Resolved": return self.Resolved(self.entrance_name, player=world.player) @override @@ -675,62 +774,8 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa ] -class RuleWorldMixin(World): - rule_ids: dict[int, Rule.Resolved] - rule_dependencies: dict[str, set[int]] - - custom_rule_classes: ClassVar[dict[str, type[Rule]]] - - def __init__(self, multiworld: "MultiWorld", player: int) -> None: - super().__init__(multiworld, player) - self.rule_ids = {} - self.rule_dependencies = defaultdict(set) - - @classmethod - def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule": - name = data.get("rule", "") - if name not in DEFAULT_RULES and name not in getattr(cls, "custom_rule_classes", {}): - raise ValueError("Rule not found") - rule_class = cls.custom_rule_classes[name] or DEFAULT_RULES.get(name) - return rule_class.from_json(data) - - def resolve_rule(self, rule: "Rule") -> "Rule.Resolved": - resolved_rule = rule.resolve(self) - for item_name, rule_ids in resolved_rule.item_dependencies().items(): - self.rule_dependencies[item_name] |= rule_ids - return resolved_rule - - def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: - for indirect_region in resolved_rule.indirect_regions(): - self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) - - def set_rule(self, spot: "Location | Entrance", rule: "Rule") -> None: - resolved_rule = self.resolve_rule(rule) - spot.access_rule = resolved_rule.test - if self.explicit_indirect_conditions and isinstance(spot, Entrance): - self.register_rule_connections(resolved_rule, spot) - - @override - def collect(self, state: "CollectionState", item: "Item") -> bool: - changed = super().collect(state, item) - if changed and getattr(self, "rule_dependencies", None): - player_results: dict[int, bool] = state.rule_cache[self.player] - for rule_id in self.rule_dependencies[item.name]: - _ = player_results.pop(rule_id, None) - return changed - - @override - def remove(self, state: "CollectionState", item: "Item") -> bool: - changed = super().remove(state, item) - if changed and getattr(self, "rule_dependencies", None): - player_results: dict[int, bool] = state.rule_cache[self.player] - for rule_id in self.rule_dependencies[item.name]: - _ = player_results.pop(rule_id, None) - return changed - - DEFAULT_RULES = { - rule_name: rule_class + rule_name: cast("type[Rule[RuleWorldMixin]]", rule_class) for rule_name, rule_class in locals().items() if isinstance(rule_class, type) and issubclass(rule_class, Rule) and rule_class is not Rule } From 985d5458d650f0e1e7104142bf530eb6b1d48fc6 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 15 Jun 2025 23:43:10 -0400 Subject: [PATCH 019/135] add string explains --- rule_builder.py | 144 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 15 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 922474f44a8b..861e10114d1c 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -3,9 +3,9 @@ import operator from collections import defaultdict from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, TypeVar, cast, dataclass_transform +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, TypeVar, cast -from typing_extensions import Never, Self, override +from typing_extensions import Never, Self, dataclass_transform, override from BaseClasses import Entrance @@ -273,10 +273,18 @@ def indirect_regions(self) -> tuple[str, ...]: """Returns a tuple of region names this rule is indirectly connected to""" return () - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": """Returns a list of printJSON messages that explain the logic for this rule""" return [{"type": "text", "text": self.__class__.__name__}] + def explain_str(self, state: "CollectionState | None" = None) -> str: + """Returns a human readable string describing this rule""" + return str(self) + + @override + def __str__(self) -> str: + return f"{self.__class__.__name__}()" + @dataclasses.dataclass() class True_(Rule[TWorld]): @@ -292,9 +300,13 @@ def _evaluate(self, state: "CollectionState") -> bool: return True @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [{"type": "color", "color": "green", "text": "True"}] + @override + def __str__(self) -> str: + return "True" + @dataclasses.dataclass() class False_(Rule[TWorld]): @@ -310,9 +322,13 @@ def _evaluate(self, state: "CollectionState") -> bool: return False @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [{"type": "color", "color": "salmon", "text": "False"}] + @override + def __str__(self) -> str: + return "False" + @dataclasses.dataclass(init=False) class NestedRule(Rule[TWorld]): @@ -381,15 +397,25 @@ def _evaluate(self, state: "CollectionState") -> bool: return True @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] for i, child in enumerate(self.children): if i > 0: messages.append({"type": "text", "text": " & "}) - messages.extend(child.explain(state)) + messages.extend(child.explain_json(state)) messages.append({"type": "text", "text": ")"}) return messages + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + clauses = " & ".join([c.explain_str(state) for c in self.children]) + return f"({clauses})" + + @override + def __str__(self) -> str: + clauses = " & ".join([str(c) for c in self.children]) + return f"({clauses})" + @override def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) @@ -456,15 +482,25 @@ def _evaluate(self, state: "CollectionState") -> bool: return False @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] for i, child in enumerate(self.children): if i > 0: messages.append({"type": "text", "text": " | "}) - messages.extend(child.explain(state)) + messages.extend(child.explain_json(state)) messages.append({"type": "text", "text": ")"}) return messages + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + clauses = " | ".join([c.explain_str(state) for c in self.children]) + return f"({clauses})" + + @override + def __str__(self) -> str: + clauses = " | ".join([str(c) for c in self.children]) + return f"({clauses})" + @override def simplify(self) -> "Rule.Resolved": children_to_process = list(self.children) @@ -545,7 +581,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {self.item_name: {id(self)}} @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] if self.count > 1: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) @@ -553,6 +589,19 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa messages.append({"type": "item_name", "flags": 0b001, "text": self.item_name, "player": self.player}) return messages + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + prefix = "Has" if self.test(state) else "Missing" + count = f"{self.count}x " if self.count > 1 else "" + return f"{prefix} {count}{self.item_name}" + + @override + def __str__(self) -> str: + count = f"{self.count}x " if self.count > 1 else "" + return f"Has {count}{self.item_name}" + @dataclasses.dataclass(init=False) class HasAll(Rule[TWorld]): @@ -592,7 +641,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "all"}, @@ -605,6 +654,22 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa messages.append({"type": "text", "text": ")"}) return messages + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + prefix = "Has all" if self.test(state) else "Missing some" + found_str = f"Found: {', '.join(found)}" if found else "" + missing_str = f"Missing: {', '.join(missing)}" if missing else "" + return f"{prefix} of ({found_str}{missing_str})" + + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + return f"Has all of ({items})" + @dataclasses.dataclass() class HasAny(Rule[TWorld]): @@ -644,7 +709,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "any"}, @@ -657,6 +722,22 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa messages.append({"type": "text", "text": ")"}) return messages + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + prefix = "Has some" if self.test(state) else "Missing all" + found_str = f"Found: {', '.join(found)}" if found else "" + missing_str = f"Missing: {', '.join(missing)}" if missing else "" + return f"{prefix} of ({found_str}{missing_str})" + + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + return f"Has all of ({items})" + @dataclasses.dataclass() class CanReachLocation(Rule[TWorld]): @@ -703,12 +784,23 @@ def indirect_regions(self) -> tuple[str, ...]: return () @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ {"type": "text", "text": "Reached Location "}, {"type": "location_name", "text": self.location_name, "player": self.player}, ] + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + prefix = "Reached" if self.test(state) else "Cannot reach" + return f"{prefix} location {self.location_name}" + + @override + def __str__(self) -> str: + return f"Can reach location {self.location_name}" + @dataclasses.dataclass() class CanReachRegion(Rule[TWorld]): @@ -737,12 +829,23 @@ def indirect_regions(self) -> tuple[str, ...]: return (self.region_name,) @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ {"type": "text", "text": "Reached Region "}, {"type": "color", "color": "yellow", "text": self.region_name}, ] + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + prefix = "Reached" if self.test(state) else "Cannot reach" + return f"{prefix} region {self.region_name}" + + @override + def __str__(self) -> str: + return f"Can reach region {self.region_name}" + @dataclasses.dataclass() class CanReachEntrance(Rule[TWorld]): @@ -767,12 +870,23 @@ def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_entrance(self.entrance_name, self.player) @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ {"type": "text", "text": "Reached Entrance "}, {"type": "entrance_name", "text": self.entrance_name, "player": self.player}, ] + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + prefix = "Reached" if self.test(state) else "Cannot reach" + return f"{prefix} entrance {self.entrance_name}" + + @override + def __str__(self) -> str: + return f"Can reach entrance {self.entrance_name}" + DEFAULT_RULES = { rule_name: cast("type[Rule[RuleWorldMixin]]", rule_class) From 39221ff68e29accd9bd8a878c806a098731c528b Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 15 Jun 2025 23:56:17 -0400 Subject: [PATCH 020/135] start conversion to rule builder --- worlds/astalon/logic/__init__.py | 10 - worlds/astalon/logic/custom_rules.py | 56 +-- worlds/astalon/logic/factories.py | 476 -------------------------- worlds/astalon/logic/instances.py | 424 ----------------------- worlds/astalon/logic/main_campaign.py | 13 +- worlds/astalon/logic/mixin.py | 26 -- worlds/astalon/world.py | 26 +- 7 files changed, 48 insertions(+), 983 deletions(-) delete mode 100644 worlds/astalon/logic/factories.py delete mode 100644 worlds/astalon/logic/instances.py delete mode 100644 worlds/astalon/logic/mixin.py diff --git a/worlds/astalon/logic/__init__.py b/worlds/astalon/logic/__init__.py index d2df41e26498..e69de29bb2d1 100644 --- a/worlds/astalon/logic/__init__.py +++ b/worlds/astalon/logic/__init__.py @@ -1,10 +0,0 @@ -from .instances import RuleInstance -from .main_campaign import MAIN_ENTRANCE_RULES, MAIN_LOCATION_RULES -from .mixin import AstalonLogicMixin - -__all__ = ( - "MAIN_ENTRANCE_RULES", - "MAIN_LOCATION_RULES", - "AstalonLogicMixin", - "RuleInstance", -) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 52c65de84e36..38e73b2366da 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -30,6 +30,7 @@ RandomizeSwitches, RandomizeWhiteKeys, ) +from ..world import AstalonWorld if TYPE_CHECKING: from collections.abc import Iterable @@ -40,7 +41,6 @@ from ..locations import LocationName from ..regions import RegionName - from ..world import AstalonWorld ITEM_DEPS: "dict[str, tuple[Character, ...]]" = { @@ -86,8 +86,8 @@ def _printjson_item(item: str, player: int, state: "CollectionState | None" = No return message -@dataclasses.dataclass() -class Has(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class Has(rule_builder.Rule[AstalonWorld]): item_name: "ItemName | Events" count: int = 1 @@ -129,8 +129,8 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa return messages -@dataclasses.dataclass() -class HasAll(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class HasAll(rule_builder.Rule[AstalonWorld]): item_names: "tuple[ItemName | Events, ...]" def __init__( @@ -223,8 +223,8 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa return messages -@dataclasses.dataclass() -class HasAny(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class HasAny(rule_builder.Rule[AstalonWorld]): item_names: "tuple[ItemName | Events, ...]" def __init__( @@ -317,12 +317,12 @@ def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePa return messages -@dataclasses.dataclass() -class CanReachLocation(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class CanReachLocation(rule_builder.Rule[AstalonWorld]): location_name: "LocationName" @override - def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": location = world.get_location(self.location_name) if not location.parent_region: raise ValueError(f"Location {location.name} has no parent region") @@ -339,12 +339,12 @@ class Resolved(rule_builder.CanReachLocation.Resolved): pass -@dataclasses.dataclass() -class CanReachRegion(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class CanReachRegion(rule_builder.Rule[AstalonWorld]): region_name: "RegionName" @override - def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": return self.Resolved(self.region_name.value, player=world.player) @override @@ -357,13 +357,13 @@ class Resolved(rule_builder.CanReachRegion.Resolved): pass -@dataclasses.dataclass() -class CanReachEntrance(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class CanReachEntrance(rule_builder.Rule[AstalonWorld]): from_region: "RegionName" to_region: "RegionName" @override - def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": entrance = f"{self.from_region.value} -> {self.to_region.value}" return self.Resolved(entrance, player=world.player) @@ -377,13 +377,13 @@ class Resolved(rule_builder.CanReachEntrance.Resolved): pass -@dataclasses.dataclass(init=False) +@rule_builder.custom_rule(AstalonWorld, init=False) class ToggleRule(HasAll): option_cls: "ClassVar[type[Option[int]]]" otherwise: bool = False @override - def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": if len(self.item_names) == 1: rule = Has(self.item_names[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) else: @@ -398,7 +398,7 @@ def _instantiate(self, world: "rule_builder.RuleWorldMixin") -> "rule_builder.Ru return rule.resolve(world) -@dataclasses.dataclass(init=False) +@rule_builder.custom_rule(AstalonWorld, init=False) class HasWhite(ToggleRule): option_cls = RandomizeWhiteKeys @@ -412,7 +412,7 @@ def __init__( self.otherwise = otherwise -@dataclasses.dataclass(init=False) +@rule_builder.custom_rule(AstalonWorld, init=False) class HasBlue(ToggleRule): option_cls = RandomizeBlueKeys @@ -426,7 +426,7 @@ def __init__( self.otherwise = otherwise -@dataclasses.dataclass(init=False) +@rule_builder.custom_rule(AstalonWorld, init=False) class HasRed(ToggleRule): option_cls = RandomizeRedKeys @@ -440,7 +440,7 @@ def __init__( self.otherwise = otherwise -@dataclasses.dataclass(init=False) +@rule_builder.custom_rule(AstalonWorld, init=False) class HasSwitch(ToggleRule): option_cls = RandomizeSwitches @@ -454,7 +454,7 @@ def __init__( self.otherwise = otherwise -@dataclasses.dataclass(init=False) +@rule_builder.custom_rule(AstalonWorld, init=False) class HasElevator(HasAll): def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.OptionFilter[Any]]" = ()) -> None: super().__init__( @@ -464,8 +464,8 @@ def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.Opti ) -@dataclasses.dataclass() -class HasGoal(rule_builder.Rule): +@rule_builder.custom_rule(AstalonWorld) +class HasGoal(rule_builder.Rule[AstalonWorld]): @override def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": if world.options.goal.value != Goal.option_eye_hunt: @@ -477,9 +477,9 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": ) -@dataclasses.dataclass() -class HardLogic(rule_builder.Rule): - child: "rule_builder.Rule" +@rule_builder.custom_rule(AstalonWorld) +class HardLogic(rule_builder.Rule[AstalonWorld]): + child: "rule_builder.Rule[AstalonWorld]" @override def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": diff --git a/worlds/astalon/logic/factories.py b/worlds/astalon/logic/factories.py deleted file mode 100644 index 04df1981a7cc..000000000000 --- a/worlds/astalon/logic/factories.py +++ /dev/null @@ -1,476 +0,0 @@ -import dataclasses -import operator -from typing import TYPE_CHECKING, Any, ClassVar - -from ..items import Character, Events, Eye, KeyItem, ShopUpgrade -from ..options import Difficulty, Goal, RandomizeCharacters -from .instances import ( - AndInstance, - CanReachEntranceInstance, - CanReachLocationInstance, - CanReachRegionInstance, - FalseInstance, - HardLogicInstance, - HasAllInstance, - HasAnyInstance, - HasInstance, - NestedRuleInstance, - OrInstance, - TrueInstance, -) - -if TYPE_CHECKING: - from Options import CommonOptions, Option - - from ..items import BlueDoor, Crystal, Elevator, Face, ItemName, RedDoor, Switch, WhiteDoor - from ..locations import LocationName - from ..regions import RegionName - from ..world import AstalonWorld - from .instances import RuleInstance - - -ITEM_DEPS: "dict[str, tuple[Character, ...]]" = { - KeyItem.CLOAK.value: (Character.ALGUS,), - KeyItem.SWORD.value: (Character.ARIAS,), - KeyItem.BOOTS.value: (Character.ARIAS,), - KeyItem.CLAW.value: (Character.KYULI,), - KeyItem.BOW.value: (Character.KYULI,), - KeyItem.BLOCK.value: (Character.ZEEK,), - KeyItem.STAR.value: (Character.BRAM,), - KeyItem.BANISH.value: (Character.ALGUS, Character.ZEEK), - KeyItem.GAUNTLET.value: (Character.ARIAS, Character.BRAM), - ShopUpgrade.ALGUS_ARCANIST.value: (Character.ALGUS,), - ShopUpgrade.ALGUS_METEOR.value: (Character.ALGUS,), - ShopUpgrade.ALGUS_SHOCK.value: (Character.ALGUS,), - ShopUpgrade.ARIAS_GORGONSLAYER.value: (Character.ARIAS,), - ShopUpgrade.ARIAS_LAST_STAND.value: (Character.ARIAS,), - ShopUpgrade.ARIAS_LIONHEART.value: (Character.ARIAS,), - ShopUpgrade.KYULI_ASSASSIN.value: (Character.KYULI,), - ShopUpgrade.KYULI_BULLSEYE.value: (Character.KYULI,), - ShopUpgrade.KYULI_RAY.value: (Character.KYULI,), - ShopUpgrade.ZEEK_JUNKYARD.value: (Character.ZEEK,), - ShopUpgrade.ZEEK_ORBS.value: (Character.ZEEK,), - ShopUpgrade.ZEEK_LOOT.value: (Character.ZEEK,), - ShopUpgrade.BRAM_AXE.value: (Character.BRAM,), - ShopUpgrade.BRAM_HUNTER.value: (Character.BRAM,), - ShopUpgrade.BRAM_WHIPLASH.value: (Character.BRAM,), -} - -VANILLA_CHARACTERS = frozenset((Character.ALGUS, Character.ARIAS, Character.KYULI)) -OPERATORS = { - "eq": operator.eq, - "ne": operator.ne, - "gt": operator.gt, - "lt": operator.lt, - "ge": operator.ge, - "le": operator.le, - "contains": operator.contains, -} - -characters_off = ("randomize_characters", 0) -characters_on = ("randomize_characters__ge", 1) - - -@dataclasses.dataclass() -class RuleFactory: - options: dict[str, Any] = dataclasses.field(default_factory=dict, kw_only=True) - - instance_cls: "ClassVar[type[RuleInstance]]" - - def _passes_options(self, options: "CommonOptions") -> bool: - for key, value in self.options.items(): - parts = key.split("__", maxsplit=1) - option_name = parts[0] - operator = parts[1] if len(parts) > 1 else "eq" - opt: Option[Any] = getattr(options, option_name) - if not OPERATORS[operator](opt.value, value): - return False - return True - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - return self.instance_cls(player=world.player) - - def resolve(self, world: "AstalonWorld") -> "RuleInstance": - if not self._passes_options(world.options): - return FalseInstance(player=world.player) - - instance = self._instantiate(world) - rule_hash = hash(instance) - if rule_hash not in world.rule_cache: - world.rule_cache[rule_hash] = instance - return world.rule_cache[rule_hash] - - def serialize(self) -> str: - return f"{self.__class__.__name__}()" - - -@dataclasses.dataclass() -class True_(RuleFactory): - instance_cls = TrueInstance - - def serialize(self) -> str: - return "True" - - -@dataclasses.dataclass() -class False_(RuleFactory): - instance_cls = FalseInstance - - def serialize(self) -> str: - return "False" - - -@dataclasses.dataclass(init=False) -class NestedRuleFactory(RuleFactory): - children: "tuple[RuleFactory, ...]" - - instance_cls = NestedRuleInstance - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - children = [c.resolve(world) for c in self.children] - return self.instance_cls(tuple(children), player=world.player).simplify() # type: ignore - - def __init__(self, *children: "RuleFactory", options: dict[str, Any] | None = None) -> None: - super().__init__(options=options or {}) - self.children = children - - -@dataclasses.dataclass(init=False) -class And(NestedRuleFactory): - instance_cls = AndInstance - - def serialize(self) -> str: - return f"({' + '.join(child.serialize() for child in self.children)})" - - -@dataclasses.dataclass(init=False) -class Or(NestedRuleFactory): - instance_cls = OrInstance - - def serialize(self) -> str: - return f"({' | '.join(child.serialize() for child in self.children)})" - - -@dataclasses.dataclass() -class Has(RuleFactory): - item: "ItemName | Events" - count: int = 1 - - instance_cls = HasInstance - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - default = HasInstance(self.item.value, self.count, player=world.player) - - if self.item in VANILLA_CHARACTERS: - if world.options.randomize_characters == RandomizeCharacters.option_vanilla: - return TrueInstance(player=world.player) - return default - - if deps := ITEM_DEPS.get(self.item): - if world.options.randomize_characters == RandomizeCharacters.option_vanilla and ( - len(deps) > 1 or (len(deps) == 1 and deps[0] in VANILLA_CHARACTERS) - ): - return default - if len(deps) == 1: - return HasAllInstance((deps[0].value, self.item.value), player=world.player) - return OrInstance( - tuple(HasAllInstance((d.value, self.item.value), player=world.player) for d in deps), - player=world.player, - ) - - return default - - def serialize(self) -> str: - return f"Has({self.item.value})" - - -@dataclasses.dataclass(init=False) -class HasAll(RuleFactory): - items: "tuple[ItemName | Events, ...]" - - instance_cls = HasAllInstance - - def __init__(self, *items: "ItemName | Events", options: dict[str, Any] | None = None) -> None: - if len(items) != len(set(items)): - raise ValueError(f"Duplicate items detected, likely typo, items: {items}") - - super().__init__(options=options or {}) - self.items = items - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - if len(self.items) == 0: - return TrueInstance(player=world.player) - if len(self.items) == 1: - return Has(self.items[0]).resolve(world) - - new_clauses: list[RuleInstance] = [] - new_items: list[str] = [] - for item in self.items: - if item in VANILLA_CHARACTERS and world.options.randomize_characters == RandomizeCharacters.option_vanilla: - continue - deps = ITEM_DEPS.get(item, []) - if not deps: - new_items.append(item.value) - continue - - if len(deps) > 1: - if world.options.randomize_characters == RandomizeCharacters.option_vanilla: - new_items.append(item.value) - else: - new_clauses.append( - OrInstance( - tuple(HasAllInstance((d.value, item.value), player=world.player) for d in deps), - player=world.player, - ) - ) - continue - - if ( - len(deps) == 1 - and deps[0] not in self.items - and not ( - deps[0] in VANILLA_CHARACTERS - and world.options.randomize_characters == RandomizeCharacters.option_vanilla - ) - ): - new_items.append(deps[0].value) - - new_items.append(item.value) - - if len(new_clauses) == 0 and len(new_items) == 0: - return TrueInstance(player=world.player) - if len(new_items) == 1: - new_clauses.append(HasInstance(new_items[0], player=world.player)) - elif len(new_items) > 1: - new_clauses.append(HasAllInstance(tuple(new_items), player=world.player)) - if len(new_clauses) == 0: - return FalseInstance(player=world.player) - if len(new_clauses) == 1: - return new_clauses[0] - return AndInstance(tuple(new_clauses), player=world.player) - - def serialize(self) -> str: - return f"HasAll({', '.join(i.value for i in self.items)})" - - -@dataclasses.dataclass(init=False) -class HasAny(RuleFactory): - items: "tuple[ItemName | Events, ...]" - - instance_cls = HasAnyInstance - - def __init__(self, *items: "ItemName | Events", options: dict[str, Any] | None = None) -> None: - if len(items) != len(set(items)): - raise ValueError(f"Duplicate items detected, likely typo, items: {items}") - - super().__init__(options=options or {}) - self.items = items - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - if len(self.items) == 0: - return TrueInstance(player=world.player) - if len(self.items) == 1: - return Has(self.items[0]).resolve(world) - - new_clauses: list[RuleInstance] = [] - new_items: list[str] = [] - for item in self.items: - if item in VANILLA_CHARACTERS and world.options.randomize_characters == RandomizeCharacters.option_vanilla: - return TrueInstance(player=world.player) - - deps = ITEM_DEPS.get(item, []) - if not deps: - new_items.append(item.value) - continue - - if len(deps) > 1: - if world.options.randomize_characters == RandomizeCharacters.option_vanilla: - new_items.append(item.value) - else: - new_clauses.append( - OrInstance( - tuple(HasAllInstance((d.value, item.value), player=world.player) for d in deps), - player=world.player, - ) - ) - continue - - if ( - len(deps) == 1 - and deps[0] not in self.items - and not ( - deps[0] in VANILLA_CHARACTERS - and world.options.randomize_characters == RandomizeCharacters.option_vanilla - ) - ): - new_clauses.append(HasAllInstance((deps[0].value, item.value), player=world.player)) - else: - new_items.append(item.value) - - if len(new_items) == 1: - new_clauses.append(HasInstance(new_items[0], player=world.player)) - elif len(new_items) > 1: - new_clauses.append(HasAnyInstance(tuple(new_items), player=world.player)) - - if len(new_clauses) == 0: - return FalseInstance(player=world.player) - if len(new_clauses) == 1: - return new_clauses[0] - return OrInstance(tuple(new_clauses), player=world.player) - - def serialize(self) -> str: - return f"HasAny({', '.join(i.value for i in self.items)})" - - -@dataclasses.dataclass() -class CanReachLocation(RuleFactory): - location: "LocationName" - - instance_cls = CanReachLocationInstance - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - location = world.get_location(self.location.value) - if not location.parent_region: - raise ValueError(f"Location {location.name} has no parent region") - return CanReachLocationInstance(location.name, location.parent_region.name, player=world.player) - - def serialize(self) -> str: - return f"CanReachLocation({self.location.value})" - - -@dataclasses.dataclass() -class CanReachRegion(RuleFactory): - region: "RegionName" - - instance_cls = CanReachRegionInstance - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - return CanReachRegionInstance(self.region.value, player=world.player) - - def serialize(self) -> str: - return f"CanReachRegion({self.region.value})" - - -@dataclasses.dataclass() -class CanReachEntrance(RuleFactory): - from_region: "RegionName" - to_region: "RegionName" - - instance_cls = CanReachEntranceInstance - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - entrance = f"{self.from_region.value} -> {self.to_region.value}" - return CanReachEntranceInstance(entrance, player=world.player) - - def serialize(self) -> str: - return f"CanReachEntrance({self.from_region.value} -> {self.to_region.value})" - - -@dataclasses.dataclass(init=False) -class ToggleRule(HasAll): - option_name: ClassVar[str] - otherwise: bool = False - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - if len(self.items) == 1: - rule = Has(self.items[0], options={self.option_name: 1}) - else: - rule = HasAll(*self.items, options={self.option_name: 1}) - - if self.otherwise: - return Or( - rule, - True_(options={self.option_name: 0}), - ).resolve(world) - - return rule.resolve(world) - - -@dataclasses.dataclass(init=False) -class HasWhite(ToggleRule): - option_name = "randomize_white_keys" - - def __init__( - self, - *doors: "WhiteDoor", - otherwise: bool = False, - options: dict[str, Any] | None = None, - ) -> None: - super().__init__(*doors, options=options) - self.otherwise = otherwise - - -@dataclasses.dataclass(init=False) -class HasBlue(ToggleRule): - option_name = "randomize_blue_keys" - - def __init__( - self, - *doors: "BlueDoor", - otherwise: bool = False, - options: dict[str, Any] | None = None, - ) -> None: - super().__init__(*doors, options=options) - self.otherwise = otherwise - - -@dataclasses.dataclass(init=False) -class HasRed(ToggleRule): - option_name = "randomize_red_keys" - - def __init__( - self, - *doors: "RedDoor", - otherwise: bool = False, - options: dict[str, Any] | None = None, - ) -> None: - super().__init__(*doors, options=options) - self.otherwise = otherwise - - -@dataclasses.dataclass(init=False) -class HasSwitch(ToggleRule): - option_name = "randomize_switches" - - def __init__( - self, - *switches: "Switch | Crystal | Face", - otherwise: bool = False, - options: dict[str, Any] | None = None, - ) -> None: - super().__init__(*switches, options=options) - self.otherwise = otherwise - - -@dataclasses.dataclass(init=False) -class HasElevator(HasAll): - def __init__(self, elevator: "Elevator", *, options: dict[str, Any] | None = None) -> None: - options = options or {} - super().__init__(KeyItem.ASCENDANT_KEY, elevator, options={**options, "randomize_elevator": 1}) - - -@dataclasses.dataclass() -class HasGoal(RuleFactory): - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - if world.options.goal != Goal.option_eye_hunt: - return TrueInstance(player=world.player) - return HasInstance( - Eye.GOLD.value, - count=world.options.additional_eyes_required.value, - player=world.player, - ) - - -@dataclasses.dataclass() -class HardLogic(RuleFactory): - child: "RuleFactory" - - def _instantiate(self, world: "AstalonWorld") -> "RuleInstance": - if world.options.difficulty.value == Difficulty.option_hard: - return self.child.resolve(world) - if getattr(world.multiworld, "generation_is_fake", False): - return HardLogicInstance(self.child.resolve(world), player=world.player) - return FalseInstance(player=world.player) - - def serialize(self) -> str: - return f"HardLogic[{self.child.serialize()}]" diff --git a/worlds/astalon/logic/instances.py b/worlds/astalon/logic/instances.py deleted file mode 100644 index be57335566f0..000000000000 --- a/worlds/astalon/logic/instances.py +++ /dev/null @@ -1,424 +0,0 @@ -import dataclasses -import itertools -from typing import TYPE_CHECKING, ClassVar - -from ..items import Events -from ..regions import RegionName - -if TYPE_CHECKING: - from BaseClasses import CollectionState - from NetUtils import JSONMessagePart - - -def _printjson_item(item: str, player: int, state: "CollectionState | None" = None) -> "JSONMessagePart": - message: JSONMessagePart = {"type": "item_name", "flags": 0b001, "text": item, "player": player} - if state: - color = "green" if state.has(item, player) else "salmon" - if item == Events.FAKE_OOL_ITEM: - color = "glitched" - message["color"] = color - return message - - -@dataclasses.dataclass(kw_only=True, frozen=True) -class RuleInstance: - player: int - cacheable: bool = dataclasses.field(repr=False, default=True) - - always_true: ClassVar = False - always_false: ClassVar = False - - def __hash__(self) -> int: - return hash((self.__class__.__name__, *[getattr(self, f.name) for f in dataclasses.fields(self)])) - - def _evaluate(self, state: "CollectionState") -> bool: ... - - def evaluate(self, state: "CollectionState") -> bool: - result = self._evaluate(state) - if self.cacheable: - state._astalon_rule_results[self.player][id(self)] = result # type: ignore - return result - - def test(self, state: "CollectionState") -> bool: - cached_result = None - if self.cacheable: - cached_result = state._astalon_rule_results[self.player].get(id(self)) # type: ignore - if cached_result is not None: - return cached_result - return self.evaluate(state) - - def deps(self) -> "dict[str, set[int]]": - return {} - - def indirect(self) -> "tuple[RegionName, ...]": - return () - - def serialize(self) -> str: - return f"{self.__class__.__name__}()" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - return [{"type": "text", "text": self.__class__.__name__}] - - -@dataclasses.dataclass(frozen=True) -class TrueInstance(RuleInstance): - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - - always_true: ClassVar = True - - def __hash__(self) -> int: - return super().__hash__() - - def _evaluate(self, state: "CollectionState") -> bool: - return True - - def serialize(self) -> str: - return "True" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - return [{"type": "color", "color": "green", "text": "True"}] - - -@dataclasses.dataclass(frozen=True) -class FalseInstance(RuleInstance): - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - - always_false: ClassVar = True - - def __hash__(self) -> int: - return super().__hash__() - - def _evaluate(self, state: "CollectionState") -> bool: - return False - - def serialize(self) -> str: - return "False" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - return [{"type": "color", "color": "salmon", "text": "False"}] - - -@dataclasses.dataclass(frozen=True) -class NestedRuleInstance(RuleInstance): - children: "tuple[RuleInstance, ...]" - - def deps(self) -> "dict[str, set[int]]": - combined_deps: dict[str, set[int]] = {} - for child in self.children: - for item_name, rules in child.deps().items(): - if item_name in combined_deps: - combined_deps[item_name] |= rules - else: - combined_deps[item_name] = {id(self), *rules} - return combined_deps - - def indirect(self) -> "tuple[RegionName, ...]": - return tuple(itertools.chain.from_iterable(child.indirect() for child in self.children)) - - def simplify(self) -> "RuleInstance": - return self - - -@dataclasses.dataclass(frozen=True) -class AndInstance(NestedRuleInstance): - def _evaluate(self, state: "CollectionState") -> bool: - for rule in self.children: - if not rule.test(state): - return False - return True - - def serialize(self) -> str: - return f"({' + '.join(child.serialize() for child in self.children)})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] - for i, child in enumerate(self.children): - if i > 0: - messages.append({"type": "text", "text": " & "}) - messages.extend(child.explain(state)) - messages.append({"type": "text", "text": ")"}) - return messages - - def simplify(self) -> "RuleInstance": - children_to_process = list(self.children) - clauses: list[RuleInstance] = [] - items: set[str] = set() - true_rule: RuleInstance | None = None - - while children_to_process: - child = children_to_process.pop(0) - if child.always_false: - # false always wins - return child - if child.always_true: - # dedupe trues - true_rule = child - continue - if isinstance(child, AndInstance): - children_to_process.extend(child.children) - continue - - if isinstance(child, HasInstance) and child.count == 1: - items.add(child.item) - elif isinstance(child, HasAllInstance): - items.update(child.items) - else: - clauses.append(child) - - if not clauses and not items: - return true_rule or FalseInstance(player=self.player) - if items: - if len(items) == 1: - item_rule = HasInstance(items.pop(), player=self.player) - else: - item_rule = HasAllInstance(tuple(sorted(items)), player=self.player) - if not clauses: - return item_rule - clauses.append(item_rule) - - if len(clauses) == 1: - return clauses[0] - return AndInstance( - tuple(clauses), - player=self.player, - cacheable=self.cacheable and all(c.cacheable for c in clauses), - ) - - -@dataclasses.dataclass(frozen=True) -class OrInstance(NestedRuleInstance): - def _evaluate(self, state: "CollectionState") -> bool: - for rule in self.children: - if rule.test(state): - return True - return False - - def serialize(self) -> str: - return f"({' | '.join(child.serialize() for child in self.children)})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] - for i, child in enumerate(self.children): - if i > 0: - messages.append({"type": "text", "text": " | "}) - messages.extend(child.explain(state)) - messages.append({"type": "text", "text": ")"}) - return messages - - def simplify(self) -> "RuleInstance": - children_to_process = list(self.children) - clauses: list[RuleInstance] = [] - items: set[str] = set() - - while children_to_process: - child = children_to_process.pop(0) - if child.always_true: - # true always wins - return child - if child.always_false: - # falses can be ignored - continue - if isinstance(child, OrInstance): - children_to_process.extend(child.children) - continue - - if isinstance(child, HasInstance) and child.count == 1: - items.add(child.item) - elif isinstance(child, HasAnyInstance): - items.update(child.items) - else: - clauses.append(child) - - if not clauses and not items: - return FalseInstance(player=self.player) - if items: - if len(items) == 1: - item_rule = HasInstance(items.pop(), player=self.player) - else: - item_rule = HasAnyInstance(tuple(sorted(items)), player=self.player) - if not clauses: - return item_rule - clauses.append(item_rule) - - if len(clauses) == 1: - return clauses[0] - return OrInstance( - tuple(clauses), - player=self.player, - cacheable=self.cacheable and all(c.cacheable for c in clauses), - ) - - -@dataclasses.dataclass(frozen=True) -class HasInstance(RuleInstance): - item: str - count: int = 1 - - def __hash__(self) -> int: - return super().__hash__() - - def _evaluate(self, state: "CollectionState") -> bool: - return state.has(self.item, self.player, count=self.count) - - def deps(self) -> dict[str, set[int]]: - return {self.item: {id(self)}} - - def serialize(self) -> str: - count_display = f", count={self.count}" if self.count > 1 else "" - return f"Has({self.item}{count_display})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] - if self.count > 1: - messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) - messages.append({"type": "text", "text": "x "}) - messages.append(_printjson_item(self.item, self.player, state)) - return messages - - -@dataclasses.dataclass(frozen=True) -class HasAllInstance(RuleInstance): - items: tuple[str, ...] - - def __hash__(self) -> int: - return super().__hash__() - - def _evaluate(self, state: "CollectionState") -> bool: - return state.has_all(self.items, self.player) - - def deps(self) -> dict[str, set[int]]: - return {item: {id(self)} for item in self.items} - - def serialize(self) -> str: - return f"HasAll({', '.join(self.items)})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [ - {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": "all"}, - {"type": "text", "text": " of ("}, - ] - for i, item in enumerate(self.items): - if i > 0: - messages.append({"type": "text", "text": ", "}) - messages.append(_printjson_item(item, self.player, state)) - messages.append({"type": "text", "text": ")"}) - return messages - - -@dataclasses.dataclass(frozen=True) -class HasAnyInstance(RuleInstance): - items: tuple[str, ...] - - def __hash__(self) -> int: - return super().__hash__() - - def _evaluate(self, state: "CollectionState") -> bool: - return state.has_any(self.items, self.player) - - def deps(self) -> dict[str, set[int]]: - return {item: {id(self)} for item in self.items} - - def serialize(self) -> str: - return f"HasAny({', '.join(self.items)})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [ - {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": "any"}, - {"type": "text", "text": " of ("}, - ] - for i, item in enumerate(self.items): - if i > 0: - messages.append({"type": "text", "text": ", "}) - messages.append(_printjson_item(item, self.player, state)) - messages.append({"type": "text", "text": ")"}) - return messages - - -@dataclasses.dataclass(frozen=True) -class CanReachLocationInstance(RuleInstance): - location: str - parent_region: str - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - - def _evaluate(self, state: "CollectionState") -> bool: - return state.can_reach_location(self.location, self.player) - - def indirect(self) -> "tuple[RegionName, ...]": - return (RegionName(self.parent_region),) - - def serialize(self) -> str: - return f"CanReachLocation({self.location})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - return [ - {"type": "text", "text": "Reached Location "}, - {"type": "location_name", "text": self.location, "player": self.player}, - ] - - -@dataclasses.dataclass(frozen=True) -class CanReachRegionInstance(RuleInstance): - region: str - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - - def _evaluate(self, state: "CollectionState") -> bool: - return state.can_reach_region(self.region, self.player) - - def indirect(self) -> "tuple[RegionName, ...]": - return (RegionName(self.region),) - - def serialize(self) -> str: - return f"CanReachRegion({self.region})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - return [ - {"type": "text", "text": "Reached Region "}, - {"type": "color", "color": "yellow", "text": self.region}, - ] - - -@dataclasses.dataclass(frozen=True) -class CanReachEntranceInstance(RuleInstance): - entrance: str - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - - def _evaluate(self, state: "CollectionState") -> bool: - return state.can_reach_entrance(self.entrance, self.player) - - def serialize(self) -> str: - return f"CanReachEntrance({self.entrance})" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - return [ - {"type": "text", "text": "Reached Entrance "}, - {"type": "entrance_name", "text": self.entrance, "player": self.player}, - ] - - -@dataclasses.dataclass(frozen=True) -class HardLogicInstance(RuleInstance): - child: RuleInstance - - def _evaluate(self, state: "CollectionState") -> bool: - return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child.test(state) - - def deps(self) -> "dict[str, set[int]]": - deps = self.child.deps() - deps.setdefault(Events.FAKE_OOL_ITEM.value, set()).add(id(self)) - return deps - - def indirect(self) -> "tuple[RegionName, ...]": - return self.child.indirect() - - def serialize(self) -> str: - return f"HardLogic[{self.child.serialize()}]" - - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: "list[JSONMessagePart]" = [ - {"type": "color", "color": "glitched", "text": "Hard Logic ["}, - ] - messages.extend(self.child.explain(state)) - messages.append({"type": "color", "color": "glitched", "text": "]"}) - return messages diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 3e121cb83428..5001c2de705e 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1,4 +1,6 @@ -from rule_builder import And, OptionFilter, Or, Rule, True_ +from typing import TYPE_CHECKING + +from rule_builder import And, OptionFilter, Or, True_ from ..items import ( BlueDoor, @@ -39,6 +41,11 @@ HasWhite, ) +if TYPE_CHECKING: + from rule_builder import Rule + + from ..world import AstalonWorld + easy = [OptionFilter(Difficulty, Difficulty.option_easy)] characters_off = [OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)] characters_on = [OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")] @@ -90,7 +97,7 @@ shop_moderate = CanReachRegion(R.MECH_START) shop_expensive = CanReachRegion(R.ROA_START) -MAIN_ENTRANCE_RULES: dict[tuple[R, R], Rule] = { +MAIN_ENTRANCE_RULES: "dict[tuple[R, R], Rule[AstalonWorld]]" = { (R.SHOP, R.SHOP_ALGUS): Has(Character.ALGUS), (R.SHOP, R.SHOP_ARIAS): Has(Character.ARIAS), (R.SHOP, R.SHOP_KYULI): Has(Character.KYULI), @@ -1028,7 +1035,7 @@ (R.SP_STAR_END, R.SP_STAR_CONNECTION): And(Has(KeyItem.STAR), HasSwitch(Switch.SP_AFTER_STAR)), } -MAIN_LOCATION_RULES: dict[L, Rule] = { +MAIN_LOCATION_RULES: "dict[L, Rule[AstalonWorld]]" = { L.GT_GORGONHEART: Or( HasSwitch(Switch.GT_GH, otherwise=True), HasAny(Character.KYULI, KeyItem.ICARUS, KeyItem.BLOCK, KeyItem.CLOAK, KeyItem.BOOTS), diff --git a/worlds/astalon/logic/mixin.py b/worlds/astalon/logic/mixin.py deleted file mode 100644 index 3dd526c7adcb..000000000000 --- a/worlds/astalon/logic/mixin.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import TYPE_CHECKING - -from worlds.AutoWorld import LogicMixin - -from ..constants import GAME_NAME - -if TYPE_CHECKING: - from BaseClasses import CollectionState, MultiWorld -else: - CollectionState = object - - -class AstalonLogicMixin(LogicMixin, CollectionState): - multiworld: "MultiWorld" - - _astalon_rule_results: dict[int, dict[int, bool]] - - def init_mixin(self, multiworld: "MultiWorld") -> None: - players = multiworld.get_game_players(GAME_NAME) - self._astalon_rule_results = {player: {} for player in players} - - def copy_mixin(self, new_state: "AstalonLogicMixin") -> "AstalonLogicMixin": - new_state._astalon_rule_results = { - player: rule_results.copy() for player, rule_results in self._astalon_rule_results.items() - } - return new_state diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index c06e4e39811d..e3b0d2bc817b 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -38,7 +38,6 @@ location_name_to_id, location_table, ) -from .logic import MAIN_ENTRANCE_RULES, MAIN_LOCATION_RULES from .options import ApexElevator, AstalonOptions, Goal, RandomizeCharacters from .regions import RegionName, astalon_regions from .tracker import TRACKER_WORLD @@ -111,7 +110,7 @@ class AstalonWebWorld(WebWorld): # TODO: Wrap rule, connect helper, better world typing (generic) -class AstalonWorld(RuleWorldMixin, World): +class AstalonWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] """ Uphold your pact with the Titan of Death, Epimetheus! Fight, climb and solve your way through a twisted tower as three unique adventurers, @@ -121,7 +120,7 @@ class AstalonWorld(RuleWorldMixin, World): game = GAME_NAME web = AstalonWebWorld() options_dataclass = AstalonOptions - options: AstalonOptions # type: ignore + options: AstalonOptions # type: ignore # pyright: ignore[reportIncompatibleVariableOverride] item_name_groups = item_name_groups location_name_groups = location_name_groups item_name_to_id = item_name_to_id @@ -173,6 +172,8 @@ def generate_early(self) -> None: self.extra_gold_eyes = slot_data["extra_gold_eyes"] def create_location(self, name: str) -> AstalonLocation: + from .logic.main_campaign import MAIN_LOCATION_RULES + location_name = LocationName(name) data = location_table[name] region = self.get_region(data.region.value) @@ -184,6 +185,8 @@ def create_location(self, name: str) -> AstalonLocation: return location def create_regions(self) -> None: + from .logic.main_campaign import MAIN_ENTRANCE_RULES + for region_name in astalon_regions: region = Region(region_name.value, self.player, self.multiworld) self.multiworld.regions.append(region) @@ -191,21 +194,12 @@ def create_regions(self) -> None: for region_name, region_data in astalon_regions.items(): region = self.get_region(region_name.value) for exit_region_name in region_data.exits: + exit_region = self.get_region(exit_region_name.value) region_pair = (region_name, exit_region_name) rule = MAIN_ENTRANCE_RULES.get(region_pair) - resolved_rule = None - if rule is not None: - resolved_rule = self.resolve_rule(rule) - if resolved_rule.always_false: - logger.debug(f"No matching rules for {region_name.value} -> {exit_region_name.value}") - continue - - entrance = region.connect( - self.get_region(exit_region_name.value), - rule=resolved_rule.test if resolved_rule else None, - ) - if resolved_rule: - self.register_rule_connections(resolved_rule, entrance) + entrance = self.create_entrance(region, exit_region, rule) + if not entrance: + logger.debug(f"No matching rules for {region_name.value} -> {exit_region_name.value}") logic_groups: set[str] = set() if self.options.randomize_key_items: From 7f150f271c5467707035fdca2650cd16bb992cfd Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 00:30:26 -0400 Subject: [PATCH 021/135] move simplify code to world --- rule_builder.py | 220 +++++++++++++++++++++++++----------------------- 1 file changed, 113 insertions(+), 107 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 861e10114d1c..bc659e031433 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -76,6 +76,118 @@ def create_entrance( self.register_rule_connections(resolved_rule, entrance) return entrance + def simplify_rule(self, rule: "Rule.Resolved") -> "Rule.Resolved": + if isinstance(rule, And.Resolved): + return self._simplify_and(rule) + if isinstance(rule, Or.Resolved): + return self._simplify_or(rule) + return rule + + def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": + children_to_process = list(rule.children) + clauses: list[Rule.Resolved] = [] + items: dict[str, int] = {} + true_rule: Rule.Resolved | None = None + + while children_to_process: + child = children_to_process.pop(0) + if child.always_false: + # false always wins + return child + if child.always_true: + # dedupe trues + true_rule = child + continue + if isinstance(child, And.Resolved): + children_to_process.extend(child.children) + continue + + if isinstance(child, Has.Resolved): + if child.item_name not in items or items[child.item_name] < child.count: + items[child.item_name] = child.count + elif isinstance(child, HasAll.Resolved): + for item in child.item_names: + if item not in items: + items[item] = 1 + else: + clauses.append(child) + + if not clauses and not items: + return true_rule or False_.Resolved(player=rule.player) + + has_cls = cast("type[Has[Self]]", self.get_rule_cls("Has")) + has_all_cls = cast("type[HasAll[Self]]", self.get_rule_cls("HasAll")) + has_all_items: list[str] = [] + for item, count in items.items(): + if count == 1: + has_all_items.append(item) + else: + clauses.append(has_cls.Resolved(item, count, player=rule.player)) + + if len(has_all_items) == 1: + clauses.append(has_cls.Resolved(has_all_items[0], player=rule.player)) + elif len(has_all_items) > 1: + clauses.append(has_all_cls.Resolved(tuple(has_all_items), player=rule.player)) + + if len(clauses) == 1: + return clauses[0] + return And.Resolved( + tuple(clauses), + player=rule.player, + cacheable=rule.cacheable and all(c.cacheable for c in clauses), + ) + + def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": + children_to_process = list(rule.children) + clauses: list[Rule.Resolved] = [] + items: dict[str, int] = {} + + while children_to_process: + child = children_to_process.pop(0) + if child.always_true: + # true always wins + return child + if child.always_false: + # falses can be ignored + continue + if isinstance(child, Or.Resolved): + children_to_process.extend(child.children) + continue + + if isinstance(child, Has.Resolved): + if child.item_name not in items or child.count < items[child.item_name]: + items[child.item_name] = child.count + elif isinstance(child, HasAny.Resolved): + for item in child.item_names: + items[item] = 1 + else: + clauses.append(child) + + if not clauses and not items: + return False_.Resolved(player=rule.player) + + has_cls = cast("type[Has[Self]]", self.get_rule_cls("Has")) + has_any_cls = cast("type[HasAny[Self]]", self.get_rule_cls("HasAny")) + has_any_items: list[str] = [] + for item, count in items.items(): + if count == 1: + has_any_items.append(item) + else: + clauses.append(has_cls.Resolved(item, count, player=rule.player)) + + if len(has_any_items) == 1: + clauses.append(has_cls.Resolved(has_any_items[0], player=rule.player)) + elif len(has_any_items) > 1: + clauses.append(has_any_cls.Resolved(tuple(has_any_items), player=rule.player)) + + if len(clauses) == 1: + return clauses[0] + return Or.Resolved( + tuple(clauses), + player=rule.player, + cacheable=rule.cacheable and all(c.cacheable for c in clauses), + ) + @override def collect(self, state: "CollectionState", item: "Item") -> bool: changed = super().collect(state, item) @@ -341,7 +453,7 @@ def __init__(self, *children: "Rule[TWorld]", options: "Iterable[OptionFilter[An @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": children = [c.resolve(world) for c in self.children] - return self.Resolved(tuple(children), player=world.player).simplify() + return world.simplify_rule(self.Resolved(tuple(children), player=world.player)) @override def to_json(self) -> Mapping[str, Any]: @@ -381,9 +493,6 @@ def item_dependencies(self) -> dict[str, set[int]]: def indirect_regions(self) -> tuple[str, ...]: return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) - def simplify(self) -> "Rule.Resolved": - return self - @dataclasses.dataclass(init=False) class And(NestedRule[TWorld]): @@ -416,59 +525,6 @@ def __str__(self) -> str: clauses = " & ".join([str(c) for c in self.children]) return f"({clauses})" - @override - def simplify(self) -> "Rule.Resolved": - children_to_process = list(self.children) - clauses: list[Rule.Resolved] = [] - items: dict[str, int] = {} - true_rule: Rule.Resolved | None = None - - while children_to_process: - child = children_to_process.pop(0) - if child.always_false: - # false always wins - return child - if child.always_true: - # dedupe trues - true_rule = child - continue - if isinstance(child, And.Resolved): - children_to_process.extend(child.children) - continue - - if isinstance(child, Has.Resolved): - if child.item_name not in items or items[child.item_name] < child.count: - items[child.item_name] = child.count - elif isinstance(child, HasAll.Resolved): - for item in child.item_names: - if item not in items: - items[item] = 1 - else: - clauses.append(child) - - if not clauses and not items: - return true_rule or False_.Resolved(player=self.player) - - has_all_items: list[str] = [] - for item, count in items.items(): - if count == 1: - has_all_items.append(item) - else: - clauses.append(Has.Resolved(item, count, player=self.player)) - - if len(has_all_items) == 1: - clauses.append(Has.Resolved(has_all_items[0], player=self.player)) - elif len(has_all_items) > 1: - clauses.append(HasAll.Resolved(tuple(has_all_items), player=self.player)) - - if len(clauses) == 1: - return clauses[0] - return And.Resolved( - tuple(clauses), - player=self.player, - cacheable=self.cacheable and all(c.cacheable for c in clauses), - ) - @dataclasses.dataclass(init=False) class Or(NestedRule[TWorld]): @@ -501,56 +557,6 @@ def __str__(self) -> str: clauses = " | ".join([str(c) for c in self.children]) return f"({clauses})" - @override - def simplify(self) -> "Rule.Resolved": - children_to_process = list(self.children) - clauses: list[Rule.Resolved] = [] - items: dict[str, int] = {} - - while children_to_process: - child = children_to_process.pop(0) - if child.always_true: - # true always wins - return child - if child.always_false: - # falses can be ignored - continue - if isinstance(child, Or.Resolved): - children_to_process.extend(child.children) - continue - - if isinstance(child, Has.Resolved): - if child.item_name not in items or child.count < items[child.item_name]: - items[child.item_name] = child.count - elif isinstance(child, HasAny.Resolved): - for item in child.item_names: - items[item] = 1 - else: - clauses.append(child) - - if not clauses and not items: - return False_.Resolved(player=self.player) - - has_any_items: list[str] = [] - for item, count in items.items(): - if count == 1: - has_any_items.append(item) - else: - clauses.append(Has.Resolved(item, count, player=self.player)) - - if len(has_any_items) == 1: - clauses.append(Has.Resolved(has_any_items[0], player=self.player)) - elif len(has_any_items) > 1: - clauses.append(HasAny.Resolved(tuple(has_any_items), player=self.player)) - - if len(clauses) == 1: - return clauses[0] - return Or.Resolved( - tuple(clauses), - player=self.player, - cacheable=self.cacheable and all(c.cacheable for c in clauses), - ) - @dataclasses.dataclass() class Has(Rule[TWorld]): From b48a60c39c531c765e67ab994b94e862522375ca Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 00:46:03 -0400 Subject: [PATCH 022/135] add wrapper rule --- rule_builder.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/rule_builder.py b/rule_builder.py index bc659e031433..49fcc4fdc87f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -558,6 +558,71 @@ def __str__(self) -> str: return f"({clauses})" +@dataclasses.dataclass() +class Wrapper(Rule[TWorld]): + """A rule that wraps another rule to provide extra logic or data""" + + child: "Rule[TWorld]" + + @override + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + return self.Resolved(self.child.resolve(world), player=world.player) + + @override + def to_json(self) -> Mapping[str, Any]: + return { + "rule": self.__class__.__name__, + "options": self.options, + "child": self.child.to_json(), + } + + @override + @classmethod + def from_json(cls, data: Mapping[str, Any]) -> Self: + child = data.get("child") + if child is None: + raise ValueError("Child rule cannot be None") + return cls(child, options=data.get("options", ())) + + @override + def __str__(self) -> str: + return f"{self.__class__.__name__}[{self.child}]" + + @dataclasses.dataclass(frozen=True) + class Resolved(Rule.Resolved): + child: "Rule.Resolved" + + @override + def _evaluate(self, state: "CollectionState") -> bool: + return self.child._evaluate(state) + + @override + def item_dependencies(self) -> dict[str, set[int]]: + deps: dict[str, set[int]] = {} + for item_name, rules in self.child.item_dependencies().items(): + deps[item_name] = {id(self), *rules} + return deps + + @override + def indirect_regions(self) -> tuple[str, ...]: + return self.child.indirect_regions() + + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: "list[JSONMessagePart]" = [{"type": "text", "text": f"{self.__class__.__name__} ["}] + messages.extend(self.child.explain_json(state)) + messages.append({"type": "text", "text": "]"}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + return f"{self.__class__.__name__}[{self.child.explain_str(state)}]" + + @override + def __str__(self) -> str: + return f"{self.__class__.__name__}[{self.child}]" + + @dataclasses.dataclass() class Has(Rule[TWorld]): item_name: str From f398865066fe74fee553a818de2c6749836405df Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 00:52:19 -0400 Subject: [PATCH 023/135] I may need to abandon the generic typing --- test/general/test_rule_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index e1b9f35ffadf..6770ce273c1d 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -69,7 +69,7 @@ class RuleBuilderWorld(RuleWorldMixin, World): ) ) class TestSimplify(unittest.TestCase): - rules: ClassVar[tuple[Rule, Rule.Resolved]] + rules: ClassVar[tuple[Rule[RuleBuilderWorld], Rule.Resolved]] def test_simplify(self) -> None: multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) @@ -155,7 +155,7 @@ def test_gt_filtering(self) -> None: ) ) class TestComposition(unittest.TestCase): - rules: ClassVar[tuple[Rule, Rule]] + rules: ClassVar[tuple[Rule[RuleBuilderWorld], Rule[RuleBuilderWorld]]] def test_composition(self) -> None: combined_rule, expected = self.rules From 58f2c1bc5efbb1e9752a36919bf8673bc4a7a0f1 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 00:56:28 -0400 Subject: [PATCH 024/135] missing space for faris --- rule_builder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 49fcc4fdc87f..e696a9dfd633 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -734,7 +734,8 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: prefix = "Has all" if self.test(state) else "Missing some" found_str = f"Found: {', '.join(found)}" if found else "" missing_str = f"Missing: {', '.join(missing)}" if missing else "" - return f"{prefix} of ({found_str}{missing_str})" + infix = "; " if found and missing else "" + return f"{prefix} of ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: @@ -802,7 +803,8 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: prefix = "Has some" if self.test(state) else "Missing all" found_str = f"Found: {', '.join(found)}" if found else "" missing_str = f"Missing: {', '.join(missing)}" if missing else "" - return f"{prefix} of ({found_str}{missing_str})" + infix = "; " if found and missing else "" + return f"{prefix} of ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: From 44d91ec7fcfa5824227408d87764e8ff022f460b Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 12:12:04 -0400 Subject: [PATCH 025/135] fix hashing for resolved rules --- rule_builder.py | 67 ++++++++++++++++++++++++++++++- test/general/test_rule_builder.py | 12 +++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index e696a9dfd633..0bf5b1875382 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -341,6 +341,9 @@ class Resolved: cacheable: bool = dataclasses.field(repr=False, default=True) """If this rule should be cached in the state""" + rule_name: ClassVar[str] = "Rule" + """The name of this rule for hashing purposes""" + always_true: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" @@ -352,7 +355,7 @@ def __hash__(self) -> int: return hash( ( self.__class__.__module__, - self.__class__.__name__, + self.rule_name, *[getattr(self, f.name) for f in dataclasses.fields(self)], ) ) @@ -406,6 +409,7 @@ class True_(Rule[TWorld]): class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_true: ClassVar[bool] = True + rule_name: ClassVar[str] = "True_" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -419,6 +423,10 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def __str__(self) -> str: return "True" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class False_(Rule[TWorld]): @@ -428,6 +436,7 @@ class False_(Rule[TWorld]): class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_false: ClassVar[bool] = True + rule_name: ClassVar[str] = "False_" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -441,6 +450,10 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def __str__(self) -> str: return "False" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass(init=False) class NestedRule(Rule[TWorld]): @@ -477,6 +490,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" + rule_name: ClassVar[str] = "NestedRule" @override def item_dependencies(self) -> dict[str, set[int]]: @@ -493,11 +507,17 @@ def item_dependencies(self) -> dict[str, set[int]]: def indirect_regions(self) -> tuple[str, ...]: return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass(init=False) class And(NestedRule[TWorld]): @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): + rule_name: ClassVar[str] = "And" + @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: @@ -525,11 +545,17 @@ def __str__(self) -> str: clauses = " & ".join([str(c) for c in self.children]) return f"({clauses})" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass(init=False) class Or(NestedRule[TWorld]): @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): + rule_name: ClassVar[str] = "Or" + @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: @@ -557,6 +583,10 @@ def __str__(self) -> str: clauses = " | ".join([str(c) for c in self.children]) return f"({clauses})" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class Wrapper(Rule[TWorld]): @@ -591,6 +621,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): child: "Rule.Resolved" + rule_name: ClassVar[str] = "Wrapper" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -622,6 +653,10 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"{self.__class__.__name__}[{self.child}]" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class Has(Rule[TWorld]): @@ -642,6 +677,7 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): item_name: str count: int = 1 + rule_name: ClassVar[str] = "Has" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -673,6 +709,10 @@ def __str__(self) -> str: count = f"{self.count}x " if self.count > 1 else "" return f"Has {count}{self.item_name}" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass(init=False) class HasAll(Rule[TWorld]): @@ -702,6 +742,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] + rule_name: ClassVar[str] = "HasAll" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -742,6 +783,10 @@ def __str__(self) -> str: items = ", ".join(self.item_names) return f"Has all of ({items})" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class HasAny(Rule[TWorld]): @@ -771,6 +816,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] + rule_name: ClassVar[str] = "HasAny" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -811,6 +857,10 @@ def __str__(self) -> str: items = ", ".join(self.item_names) return f"Has all of ({items})" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class CanReachLocation(Rule[TWorld]): @@ -845,6 +895,7 @@ class Resolved(Rule.Resolved): location_name: str parent_region_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + rule_name: ClassVar[str] = "CanReachLocation" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -874,6 +925,10 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"Can reach location {self.location_name}" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class CanReachRegion(Rule[TWorld]): @@ -892,6 +947,7 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): region_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + rule_name: ClassVar[str] = "CanReachRegion" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -919,6 +975,10 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"Can reach region {self.region_name}" + @override + def __hash__(self) -> int: + return super().__hash__() + @dataclasses.dataclass() class CanReachEntrance(Rule[TWorld]): @@ -937,6 +997,7 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): entrance_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + rule_name: ClassVar[str] = "CanReachEntrance" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -960,6 +1021,10 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"Can reach entrance {self.entrance_name}" + @override + def __hash__(self) -> int: + return super().__hash__() + DEFAULT_RULES = { rule_name: cast("type[Rule[RuleWorldMixin]]", rule_class) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 6770ce273c1d..ed5a04edd1fa 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -5,7 +5,7 @@ from typing_extensions import override from Options import Choice, PerGameCommonOptions, Toggle -from rule_builder import And, False_, Has, HasAll, HasAny, OptionFilter, Or, Rule, RuleWorldMixin +from rule_builder import And, False_, Has, HasAll, HasAny, OptionFilter, Or, Rule, RuleWorldMixin, True_ from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package @@ -160,3 +160,13 @@ class TestComposition(unittest.TestCase): def test_composition(self) -> None: combined_rule, expected = self.rules self.assertEqual(combined_rule, expected, str(combined_rule)) + + +class TestHashes(unittest.TestCase): + def test_hashes(self) -> None: + rule1 = And.Resolved((True_.Resolved(player=1),), player=1) + rule2 = And.Resolved((True_.Resolved(player=1),), player=1) + rule3 = Or.Resolved((True_.Resolved(player=1),), player=1) + + self.assertEqual(hash(rule1), hash(rule2)) + self.assertNotEqual(hash(rule1), hash(rule3)) From 1fb706fa57d06243c5aa2aaad299f3ebcf9b3010 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 12:20:00 -0400 Subject: [PATCH 026/135] thank u typing extensions ilu --- rule_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 0bf5b1875382..b21cf2950ff9 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -3,9 +3,9 @@ import operator from collections import defaultdict from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, cast -from typing_extensions import Never, Self, dataclass_transform, override +from typing_extensions import Never, Self, TypeVar, dataclass_transform, override from BaseClasses import Entrance @@ -207,7 +207,7 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: return changed -TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True) # noqa: PLC0105 +TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 @dataclass_transform() From 1cb9e032ac2cae7b715286983ca8a2bc06311d76 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 14:01:22 -0400 Subject: [PATCH 027/135] remove bad cacheable check --- rule_builder.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index b21cf2950ff9..713a1029529f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -44,9 +44,8 @@ def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule[Self]": def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": resolved_rule = rule.resolve(self) - if resolved_rule.cacheable: - for item_name, rule_ids in resolved_rule.item_dependencies().items(): - self.rule_dependencies[item_name] |= rule_ids + for item_name, rule_ids in resolved_rule.item_dependencies().items(): + self.rule_dependencies[item_name] |= rule_ids return resolved_rule def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: From 4a4b147276db0d9ea1cfb9b58971e5ff3e768330 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 14:02:13 -0400 Subject: [PATCH 028/135] update --- worlds/astalon/logic/custom_rules.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 38e73b2366da..1c47f36900df 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -123,8 +123,8 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(rule_builder.Has.Resolved): @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages = super().explain(state) + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages = super().explain_json(state) messages[-1] = _printjson_item(self.item_name, self.player, state) return messages @@ -209,7 +209,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(rule_builder.HasAll.Resolved): @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "all"}, @@ -303,7 +303,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(rule_builder.HasAny.Resolved): @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: list[JSONMessagePart] = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "any"}, @@ -512,10 +512,10 @@ def indirect_regions(self) -> tuple[str, ...]: return self.child.indirect_regions() @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: "list[JSONMessagePart]" = [ {"type": "color", "color": "glitched", "text": "Hard Logic ["}, ] - messages.extend(self.child.explain(state)) + messages.extend(self.child.explain_json(state)) messages.append({"type": "color", "color": "glitched", "text": "]"}) return messages From 9efa79cdf0747270bd6d9e57d12a450593702706 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 14:06:21 -0400 Subject: [PATCH 029/135] once more update to latest rule builder api --- worlds/astalon/logic/custom_rules.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 1c47f36900df..fc9165777af6 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -377,7 +377,7 @@ class Resolved(rule_builder.CanReachEntrance.Resolved): pass -@rule_builder.custom_rule(AstalonWorld, init=False) +@dataclasses.dataclass(init=False) class ToggleRule(HasAll): option_cls: "ClassVar[type[Option[int]]]" otherwise: bool = False @@ -496,6 +496,7 @@ def __str__(self) -> str: @dataclasses.dataclass(frozen=True) class Resolved(rule_builder.Rule.Resolved): child: "rule_builder.Rule.Resolved" + rule_name: ClassVar[str] = "HardLogic" @override def _evaluate(self, state: "CollectionState") -> bool: From 1b9c5c513fafe87e71e311785b2cfe0670c9326c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 15:42:20 -0400 Subject: [PATCH 030/135] add decorator to assign hash and rule name --- rule_builder.py | 126 +++++++++++------------------- test/general/test_rule_builder.py | 7 +- 2 files changed, 51 insertions(+), 82 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 713a1029529f..bbdabf60f87f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -220,6 +220,34 @@ def decorator(rule_cls: "type[Rule[TWorld]]") -> "type[Rule[TWorld]]": return decorator +def _create_hash_fn(resolved_rule_cls: "type[Rule.Resolved]") -> "Callable[..., int]": + def __hash__(self: "Rule.Resolved") -> int: + return hash( + ( + self.__class__.__module__, + self.rule_name, + *[getattr(self, f.name) for f in dataclasses.fields(self)], + ) + ) + + __hash__.__qualname__ = f"{resolved_rule_cls.__qualname__}.{__hash__.__name__}" + return __hash__ + + +@dataclass_transform(frozen_default=True, field_specifiers=(dataclasses.field, dataclasses.Field)) +def resolved_rule( + resolved_rule_cls: "type[Rule.Resolved] | None" = None, +) -> "Callable[..., type[Rule.Resolved]] | type[Rule.Resolved]": + def decorator(resolved_rule_cls: "type[Rule.Resolved]") -> "type[Rule.Resolved]": + resolved_rule_cls.__hash__ = _create_hash_fn(resolved_rule_cls) + resolved_rule_cls.rule_name = resolved_rule_cls.__qualname__ + return dataclasses.dataclass(frozen=True)(resolved_rule_cls) + + if resolved_rule_cls is None: + return decorator + return decorator(resolved_rule_cls) + + Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] OPERATORS = { @@ -337,7 +365,7 @@ class Resolved: player: int """The player this rule is for""" - cacheable: bool = dataclasses.field(repr=False, default=True) + cacheable: bool = dataclasses.field(repr=False, default=True, kw_only=True) """If this rule should be cached in the state""" rule_name: ClassVar[str] = "Rule" @@ -389,7 +417,7 @@ def indirect_regions(self) -> tuple[str, ...]: def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": """Returns a list of printJSON messages that explain the logic for this rule""" - return [{"type": "text", "text": self.__class__.__name__}] + return [{"type": "text", "text": self.rule_name}] def explain_str(self, state: "CollectionState | None" = None) -> str: """Returns a human readable string describing this rule""" @@ -397,18 +425,17 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: @override def __str__(self) -> str: - return f"{self.__class__.__name__}()" + return f"{self.rule_name}()" @dataclasses.dataclass() class True_(Rule[TWorld]): """A rule that always returns True""" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_true: ClassVar[bool] = True - rule_name: ClassVar[str] = "True_" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -422,20 +449,15 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def __str__(self) -> str: return "True" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass() class False_(Rule[TWorld]): """A rule that always returns False""" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_false: ClassVar[bool] = True - rule_name: ClassVar[str] = "False_" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -449,10 +471,6 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def __str__(self) -> str: return "False" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass(init=False) class NestedRule(Rule[TWorld]): @@ -486,10 +504,9 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({children}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" - rule_name: ClassVar[str] = "NestedRule" @override def item_dependencies(self) -> dict[str, set[int]]: @@ -506,17 +523,11 @@ def item_dependencies(self) -> dict[str, set[int]]: def indirect_regions(self) -> tuple[str, ...]: return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass(init=False) class And(NestedRule[TWorld]): - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(NestedRule.Resolved): - rule_name: ClassVar[str] = "And" - @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: @@ -544,17 +555,11 @@ def __str__(self) -> str: clauses = " & ".join([str(c) for c in self.children]) return f"({clauses})" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass(init=False) class Or(NestedRule[TWorld]): - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(NestedRule.Resolved): - rule_name: ClassVar[str] = "Or" - @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: @@ -582,10 +587,6 @@ def __str__(self) -> str: clauses = " | ".join([str(c) for c in self.children]) return f"({clauses})" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass() class Wrapper(Rule[TWorld]): @@ -617,10 +618,9 @@ def from_json(cls, data: Mapping[str, Any]) -> Self: def __str__(self) -> str: return f"{self.__class__.__name__}[{self.child}]" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): child: "Rule.Resolved" - rule_name: ClassVar[str] = "Wrapper" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -639,22 +639,18 @@ def indirect_regions(self) -> tuple[str, ...]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: "list[JSONMessagePart]" = [{"type": "text", "text": f"{self.__class__.__name__} ["}] + messages: "list[JSONMessagePart]" = [{"type": "text", "text": f"{self.rule_name} ["}] messages.extend(self.child.explain_json(state)) messages.append({"type": "text", "text": "]"}) return messages @override def explain_str(self, state: "CollectionState | None" = None) -> str: - return f"{self.__class__.__name__}[{self.child.explain_str(state)}]" + return f"{self.rule_name}[{self.child.explain_str(state)}]" @override def __str__(self) -> str: - return f"{self.__class__.__name__}[{self.child}]" - - @override - def __hash__(self) -> int: - return super().__hash__() + return f"{self.rule_name}[{self.child}]" @dataclasses.dataclass() @@ -672,11 +668,10 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name}{count}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): item_name: str count: int = 1 - rule_name: ClassVar[str] = "Has" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -708,10 +703,6 @@ def __str__(self) -> str: count = f"{self.count}x " if self.count > 1 else "" return f"Has {count}{self.item_name}" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass(init=False) class HasAll(Rule[TWorld]): @@ -738,10 +729,9 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): item_names: tuple[str, ...] - rule_name: ClassVar[str] = "HasAll" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -782,10 +772,6 @@ def __str__(self) -> str: items = ", ".join(self.item_names) return f"Has all of ({items})" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass() class HasAny(Rule[TWorld]): @@ -812,10 +798,9 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): item_names: tuple[str, ...] - rule_name: ClassVar[str] = "HasAny" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -856,10 +841,6 @@ def __str__(self) -> str: items = ", ".join(self.item_names) return f"Has all of ({items})" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass() class CanReachLocation(Rule[TWorld]): @@ -889,12 +870,11 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.location_name}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): location_name: str parent_region_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - rule_name: ClassVar[str] = "CanReachLocation" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -924,10 +904,6 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"Can reach location {self.location_name}" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass() class CanReachRegion(Rule[TWorld]): @@ -942,11 +918,10 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.region_name}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): region_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - rule_name: ClassVar[str] = "CanReachRegion" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -974,10 +949,6 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"Can reach region {self.region_name}" - @override - def __hash__(self) -> int: - return super().__hash__() - @dataclasses.dataclass() class CanReachEntrance(Rule[TWorld]): @@ -992,11 +963,10 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.entrance_name}{options})" - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): entrance_name: str cacheable: bool = dataclasses.field(repr=False, default=False, init=False) - rule_name: ClassVar[str] = "CanReachEntrance" @override def _evaluate(self, state: "CollectionState") -> bool: @@ -1020,10 +990,6 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"Can reach entrance {self.entrance_name}" - @override - def __hash__(self) -> int: - return super().__hash__() - DEFAULT_RULES = { rule_name: cast("type[Rule[RuleWorldMixin]]", rule_class) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index ed5a04edd1fa..d112e6545979 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -74,8 +74,9 @@ class TestSimplify(unittest.TestCase): def test_simplify(self) -> None: multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) world = multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) rule, expected = self.rules - resolved_rule = rule.resolve(world) # type: ignore + resolved_rule = rule.resolve(world) self.assertEqual(resolved_rule, expected, str(resolved_rule)) @@ -86,7 +87,9 @@ class TestOptions(unittest.TestCase): @override def setUp(self) -> None: self.multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) - self.world = self.multiworld.worlds[1] # type: ignore + world = self.multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + self.world = world return super().setUp() def test_option_filtering(self) -> None: From 61da2852cb03ef5963676965e0a4bc6eaebdc36c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 16:27:57 -0400 Subject: [PATCH 031/135] more type crimes... --- BaseClasses.py | 39 +++++++++++++++++++++++++++++-- rule_builder.py | 2 +- test/general/test_rule_builder.py | 18 +++++++------- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 9b278257f28b..5d2411d5e3f1 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from entrance_rando import ERPlacementState + from rule_builder import Rule from worlds import AutoWorld @@ -1075,7 +1076,8 @@ class EntranceType(IntEnum): class Entrance: - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + _access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + resolved_rule: "Rule.Resolved | None" = None hide_path: bool = False player: int name: str @@ -1092,6 +1094,22 @@ def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, self.randomization_group = randomization_group self.randomization_type = randomization_type + @property + def access_rule(self) -> Callable[[CollectionState], bool]: + return self._access_rule + + @access_rule.setter + def access_rule(self, value: "Callable[[CollectionState], bool] | Rule.Resolved") -> None: + if callable(value): + self._access_rule = value + self.resolved_rule = None + else: + self._access_rule = value.test + self.resolved_rule = value + + # purposefully shadow the property to keep backwards compat + access_rule: Callable[[CollectionState], bool] = _access_rule + def can_reach(self, state: CollectionState) -> bool: assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" if self.parent_region.can_reach(state) and self.access_rule(state): @@ -1375,7 +1393,8 @@ class Location: show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + _access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + resolved_rule: "Rule.Resolved | None" =None item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item: Optional[Item] = None @@ -1385,6 +1404,22 @@ def __init__(self, player: int, name: str = '', address: Optional[int] = None, p self.address = address self.parent_region = parent + @property + def access_rule(self) -> Callable[[CollectionState], bool]: + return self._access_rule + + @access_rule.setter + def access_rule(self, value: "Callable[[CollectionState], bool] | Rule.Resolved") -> None: + if callable(value): + self._access_rule = value + self.resolved_rule = None + else: + self._access_rule = value.test + self.resolved_rule = value + + # purposefully shadow the property to keep backwards compat + access_rule: Callable[[CollectionState], bool] = _access_rule + def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool: return (( self.always_allow(state, item) diff --git a/rule_builder.py b/rule_builder.py index bbdabf60f87f..8e3ba875334f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -54,7 +54,7 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: resolved_rule = self.resolve_rule(rule) - spot.access_rule = resolved_rule.test + spot.access_rule = resolved_rule if self.explicit_indirect_conditions and isinstance(spot, Entrance): self.register_rule_connections(resolved_rule, spot) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index d112e6545979..b2222fbeb274 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -16,14 +16,14 @@ class ToggleOption(Toggle): - auto_display_name = True + auto_display_name: ClassVar[bool] = True class ChoiceOption(Choice): - option_first = 0 - option_second = 1 - option_third = 2 - default = 0 + option_first: ClassVar[int] = 0 + option_second: ClassVar[int] = 1 + option_third: ClassVar[int] = 2 + default: ClassVar[int] = 0 @dataclass @@ -33,10 +33,10 @@ class RuleBuilderOptions(PerGameCommonOptions): class RuleBuilderWorld(RuleWorldMixin, World): - game = "Rule Builder Test Game" - item_name_to_id = {} - location_name_to_id = {} - hidden = True + game: ClassVar[str] = "Rule Builder Test Game" + item_name_to_id: ClassVar[dict[str, int]] = {} + location_name_to_id: ClassVar[dict[str, int]] = {} + hidden: ClassVar[bool] = True options_dataclass = RuleBuilderOptions options: RuleBuilderOptions # type: ignore From 730d9ecc30546e694218cecece21a9aae8fdbb6f Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 19:15:49 -0400 Subject: [PATCH 032/135] region access rules are now cached --- BaseClasses.py | 6 ++- rule_builder.py | 101 +++++++++++++++++++++++++++++++------------- worlds/AutoWorld.py | 8 +++- 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 5d2411d5e3f1..73d66f64da95 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -778,7 +778,7 @@ def update_reachable_regions(self, player: int): else: self._update_reachable_regions_auto_indirect_conditions(player, queue) - def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque): + def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque[Entrance]): reachable_regions = self.reachable_regions[player] blocked_connections = self.blocked_connections[player] # run BFS on all connections, and keep track of those blocked by missing items @@ -796,13 +796,14 @@ def _update_reachable_regions_explicit_indirect_conditions(self, player: int, qu blocked_connections.update(new_region.exits) queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) + self.multiworld.worlds[player].reached_region(self, new_region) # Retry connections if the new region can unblock them for new_entrance in self.multiworld.indirect_connections.get(new_region, set()): if new_entrance in blocked_connections and new_entrance not in queue: queue.append(new_entrance) - def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque): + def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque[Entrance]): reachable_regions = self.reachable_regions[player] blocked_connections = self.blocked_connections[player] new_connection: bool = True @@ -824,6 +825,7 @@ def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: queue.extend(new_region.exits) self.path[new_region] = (new_region.name, self.path.get(connection, None)) new_connection = True + self.multiworld.worlds[player].reached_region(self, new_region) # sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region) queue.extend(blocked_connections) diff --git a/rule_builder.py b/rule_builder.py index 8e3ba875334f..43bb3ddd7186 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1,5 +1,4 @@ import dataclasses -import itertools import operator from collections import defaultdict from collections.abc import Iterable, Mapping @@ -20,14 +19,16 @@ class RuleWorldMixin(World): rule_ids: "dict[int, Rule.Resolved]" - rule_dependencies: dict[str, set[int]] + rule_item_dependencies: dict[str, set[int]] + rule_region_dependencies: dict[str, set[int]] custom_rule_classes: "ClassVar[dict[str, type[Rule[Self]]]]" def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} - self.rule_dependencies = defaultdict(set) + self.rule_item_dependencies = defaultdict(set) + self.rule_region_dependencies = defaultdict(set) @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": @@ -45,16 +46,18 @@ def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule[Self]": def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": resolved_rule = rule.resolve(self) for item_name, rule_ids in resolved_rule.item_dependencies().items(): - self.rule_dependencies[item_name] |= rule_ids + self.rule_item_dependencies[item_name] |= rule_ids + for region_name, rule_ids in resolved_rule.region_dependencies().items(): + self.rule_region_dependencies[region_name] |= rule_ids return resolved_rule def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: - for indirect_region in resolved_rule.indirect_regions(): + for indirect_region in resolved_rule.region_dependencies().keys(): self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: resolved_rule = self.resolve_rule(rule) - spot.access_rule = resolved_rule + spot.access_rule = resolved_rule # type: ignore (this is due to backwards compat) if self.explicit_indirect_conditions and isinstance(spot, Entrance): self.register_rule_connections(resolved_rule, spot) @@ -190,21 +193,37 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": @override def collect(self, state: "CollectionState", item: "Item") -> bool: changed = super().collect(state, item) - if changed and getattr(self, "rule_dependencies", None): + if changed and getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] - for rule_id in self.rule_dependencies[item.name]: + for rule_id in self.rule_item_dependencies[item.name]: _ = player_results.pop(rule_id, None) return changed @override def remove(self, state: "CollectionState", item: "Item") -> bool: changed = super().remove(state, item) - if changed and getattr(self, "rule_dependencies", None): + + if changed and getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] - for rule_id in self.rule_dependencies[item.name]: + for rule_id in self.rule_item_dependencies[item.name]: _ = player_results.pop(rule_id, None) + + # clear all region dependent caches as none can be trusted + if changed and getattr(self, "rule_region_dependencies", None): + for rule_ids in self.rule_region_dependencies.values(): + for rule_id in rule_ids: + _ = state.rule_cache[self.player].pop(rule_id, None) + return changed + @override + def reached_region(self, state: "CollectionState", region: "Region") -> None: + super().reached_region(state, region) + if getattr(self, "rule_region_dependencies", None): + player_results: dict[int, bool] = state.rule_cache[self.player] + for rule_id in self.rule_region_dependencies[region.name]: + _ = player_results.pop(rule_id, None) + TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 @@ -408,12 +427,13 @@ def test(self, state: "CollectionState") -> bool: return self.evaluate(state) def item_dependencies(self) -> dict[str, set[int]]: - """Returns a mapping of item name to set of object ids to be used for cache invalidation""" + """Returns a mapping of item name to set of object ids, used for cache invalidation""" return {} - def indirect_regions(self) -> tuple[str, ...]: - """Returns a tuple of region names this rule is indirectly connected to""" - return () + def region_dependencies(self) -> dict[str, set[int]]: + """Returns a mapping of region name to set of object ids, + used for indirect connections and cache invalidation""" + return {} def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": """Returns a list of printJSON messages that explain the logic for this rule""" @@ -434,7 +454,6 @@ class True_(Rule[TWorld]): @resolved_rule class Resolved(Rule.Resolved): - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_true: ClassVar[bool] = True @override @@ -456,7 +475,6 @@ class False_(Rule[TWorld]): @resolved_rule class Resolved(Rule.Resolved): - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) always_false: ClassVar[bool] = True @override @@ -520,8 +538,15 @@ def item_dependencies(self) -> dict[str, set[int]]: return combined_deps @override - def indirect_regions(self) -> tuple[str, ...]: - return tuple(itertools.chain.from_iterable(child.indirect_regions() for child in self.children)) + def region_dependencies(self) -> dict[str, set[int]]: + combined_deps: dict[str, set[int]] = {} + for child in self.children: + for region_name, rules in child.region_dependencies().items(): + if region_name in combined_deps: + combined_deps[region_name] |= rules + else: + combined_deps[region_name] = {id(self), *rules} + return combined_deps @dataclasses.dataclass(init=False) @@ -634,8 +659,11 @@ def item_dependencies(self) -> dict[str, set[int]]: return deps @override - def indirect_regions(self) -> tuple[str, ...]: - return self.child.indirect_regions() + def region_dependencies(self) -> dict[str, set[int]]: + deps: dict[str, set[int]] = {} + for region_name, rules in self.child.region_dependencies().items(): + deps[region_name] = {id(self), *rules} + return deps @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -874,17 +902,16 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): location_name: str parent_region_name: str - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) @override def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_location(self.location_name, self.player) @override - def indirect_regions(self) -> tuple[str, ...]: + def region_dependencies(self) -> dict[str, set[int]]: if self.parent_region_name: - return (self.parent_region_name,) - return () + return {self.parent_region_name: {id(self)}} + return {} @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -908,6 +935,7 @@ def __str__(self) -> str: @dataclasses.dataclass() class CanReachRegion(Rule[TWorld]): region_name: str + """The name of the region to test access to""" @override def _instantiate(self, world: "TWorld") -> "Resolved": @@ -921,15 +949,14 @@ def __str__(self) -> str: @resolved_rule class Resolved(Rule.Resolved): region_name: str - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) @override def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_region(self.region_name, self.player) @override - def indirect_regions(self) -> tuple[str, ...]: - return (self.region_name,) + def region_dependencies(self) -> dict[str, set[int]]: + return {self.region_name: {id(self)}} @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -953,10 +980,20 @@ def __str__(self) -> str: @dataclasses.dataclass() class CanReachEntrance(Rule[TWorld]): entrance_name: str + """The name of the entrance to test access to""" + + parent_region_name: str = "" + """The name of the entrance's parent region. If not specified it will be resolved when the rule is resolved""" @override def _instantiate(self, world: "TWorld") -> "Resolved": - return self.Resolved(self.entrance_name, player=world.player) + parent_region_name = self.parent_region_name + if not parent_region_name: + entrance = world.get_entrance(self.entrance_name) + if not entrance.parent_region: + raise ValueError(f"Entrance {entrance.name} has no parent region") + parent_region_name = entrance.parent_region.name + return self.Resolved(self.entrance_name, parent_region_name, player=world.player) @override def __str__(self) -> str: @@ -966,12 +1003,18 @@ def __str__(self) -> str: @resolved_rule class Resolved(Rule.Resolved): entrance_name: str - cacheable: bool = dataclasses.field(repr=False, default=False, init=False) + parent_region_name: str @override def _evaluate(self, state: "CollectionState") -> bool: return state.can_reach_entrance(self.entrance_name, self.player) + @override + def region_dependencies(self) -> dict[str, set[int]]: + if self.parent_region_name: + return {self.parent_region_name: {id(self)}} + return {} + @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": return [ diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 6ea6c237d970..c518ab0094f7 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -86,7 +86,7 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut new_class.__file__ = sys.modules[new_class.__module__].__file__ if "game" in dct: if dct["game"] in AutoWorldRegister.world_types: - raise RuntimeError(f"""Game {dct["game"]} already registered in + raise RuntimeError(f"""Game {dct["game"]} already registered in {AutoWorldRegister.world_types[dct["game"]].__file__} when attempting to register from {new_class.__file__}.""") AutoWorldRegister.world_types[dct["game"]] = new_class @@ -312,7 +312,7 @@ class World(metaclass=AutoWorldRegister): explicit_indirect_conditions: bool = True """If True, the world implementation is supposed to use MultiWorld.register_indirect_condition() correctly. - If False, everything is rechecked at every step, which is slower computationally, + If False, everything is rechecked at every step, which is slower computationally, but may be desirable in complex/dynamic worlds.""" multiworld: "MultiWorld" @@ -540,6 +540,10 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: 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()) From ad07a4c915faa8d8349a27b76d652bf9f2e9f560 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 19:35:22 -0400 Subject: [PATCH 033/135] break compatibility so new features work --- BaseClasses.py | 14 ++++------- docs/rule builder.md | 24 +++++++++---------- rule_builder.py | 2 +- worlds/generic/Rules.py | 4 ++-- worlds/sc2/Locations.py | 4 ++-- worlds/sc2/__init__.py | 2 +- .../stardew_rule/rule_explain.py | 4 ++-- 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 73d66f64da95..5df35036700c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -9,7 +9,7 @@ from collections import Counter, deque from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag -from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, +from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Final, Iterable, Iterator, List, Literal, Mapping, NamedTuple, Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) import dataclasses @@ -1078,7 +1078,8 @@ class EntranceType(IntEnum): class Entrance: - _access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + default_access_rule: Final[Callable[[CollectionState], bool]] = staticmethod(lambda state: True) + _access_rule: Callable[[CollectionState], bool] = default_access_rule resolved_rule: "Rule.Resolved | None" = None hide_path: bool = False player: int @@ -1109,9 +1110,6 @@ def access_rule(self, value: "Callable[[CollectionState], bool] | Rule.Resolved" self._access_rule = value.test self.resolved_rule = value - # purposefully shadow the property to keep backwards compat - access_rule: Callable[[CollectionState], bool] = _access_rule - def can_reach(self, state: CollectionState) -> bool: assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" if self.parent_region.can_reach(state) and self.access_rule(state): @@ -1395,7 +1393,8 @@ class Location: show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) - _access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + default_access_rule: Final[Callable[[CollectionState], bool]] = staticmethod(lambda state: True) + _access_rule: Callable[[CollectionState], bool] = default_access_rule resolved_rule: "Rule.Resolved | None" =None item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item: Optional[Item] = None @@ -1419,9 +1418,6 @@ def access_rule(self, value: "Callable[[CollectionState], bool] | Rule.Resolved" self._access_rule = value.test self.resolved_rule = value - # purposefully shadow the property to keep backwards compat - access_rule: Callable[[CollectionState], bool] = _access_rule - def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool: return (( self.always_allow(state, item) diff --git a/docs/rule builder.md b/docs/rule builder.md index ee9354c83b0d..88552e039865 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -99,7 +99,7 @@ class CanGoal(Rule[MyWorld]): def _instantiate(self, world: "MyWorld") -> "Resolved": return self.Resolved(world.required_mcguffins, player=world.player) - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): goal: int @@ -117,7 +117,7 @@ If there are items that when collected will affect the result of your rule evalu ```python @custom_rule(MyWorld) class MyRule(Rule[MyWorld]): - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): item_name: str @@ -128,23 +128,23 @@ class MyRule(Rule[MyWorld]): The default `Has`, `HasAll`, and `HasAny` rules define this function already. -### Indirect connections +### Region dependencies -If your custom rule references other regions, it must define an `indirect_regions` function that returns a tuple of region names. These will be collected and indirect connections will be registered when you set this rule on an entrance. +If your custom rule references other regions, it must define an `region_dependencies` function that returns a mapping of region names to the id of your rule. These will be combined to inform the caching system and indirect connections will be registered when you set this rule on an entrance. ```python @custom_rule(MyWorld) class MyRule(Rule[MyWorld]): - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): region_name: str @override - def indirect_regions(self) -> tuple[str, ...]: - return (self.region_name,) + def indirect_regions(self) -> dict[str, set[int]]: + return {self.region_name: {id(self)}} ``` -The default `CanReachLocation` and `CanReachRegion` rules define this function already. +The default `CanReachLocation`, `CanReachRegion`, and `CanReachEntrance` rules define this function already. ## JSON serialization @@ -175,7 +175,7 @@ The `And` and `Or` rules have a slightly different format: To define a custom format, override the `to_json` function: ```python -@dataclasses.dataclass() +@custom_rule(MyWorld) class MyRule(Rule): def to_json(self) -> Any: return { @@ -187,7 +187,7 @@ class MyRule(Rule): If your logic has been done in custom JSON first, you can define a `from_json` class method on your rules to parse it correctly: ```python -@dataclasses.dataclass() +@custom_rule(MyWorld) class MyRule(Rule): @classmethod def from_json(cls, data: Any) -> Self: @@ -201,9 +201,9 @@ Resolved rules have a default implementation for an `explain` message, which ret To implement a custom message with a custom rule, override the `explain` method on your `Resolved` class: ```python -@dataclasses.dataclass() +@custom_rule(MyWorld) class MyRule(Rule): - @dataclasses.dataclass(frozen=True) + @resolved_rule class Resolved(Rule.Resolved): @override def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": diff --git a/rule_builder.py b/rule_builder.py index 43bb3ddd7186..f2306338932f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -57,7 +57,7 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: resolved_rule = self.resolve_rule(rule) - spot.access_rule = resolved_rule # type: ignore (this is due to backwards compat) + spot.access_rule = resolved_rule if self.explicit_indirect_conditions and isinstance(spot, Entrance): self.register_rule_connections(resolved_rule, spot) diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index 31d725bff722..a06f94fe66c4 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -2,7 +2,7 @@ import logging import typing -from BaseClasses import LocationProgressType, MultiWorld, Location, Region, Entrance +from BaseClasses import Entrance, Location, LocationProgressType, MultiWorld, Region if typing.TYPE_CHECKING: import BaseClasses @@ -103,7 +103,7 @@ def set_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.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: + if old_rule is Location.default_access_rule or old_rule is Entrance.default_access_rule: spot.access_rule = rule if combine == "and" else old_rule else: if combine == "and": diff --git a/worlds/sc2/Locations.py b/worlds/sc2/Locations.py index 42b1dd4d4eb0..78a63bb4b95a 100644 --- a/worlds/sc2/Locations.py +++ b/worlds/sc2/Locations.py @@ -32,7 +32,7 @@ class LocationData(NamedTuple): name: str code: Optional[int] type: LocationType - rule: Optional[Callable[[Any], bool]] = Location.access_rule + rule: Optional[Callable[[Any], bool]] = Location.default_access_rule def get_location_types(world: World, inclusion_type: LocationInclusion) -> Set[LocationType]: @@ -1623,7 +1623,7 @@ def get_locations(world: Optional[World]) -> Tuple[LocationData, ...]: for i, location_data in enumerate(location_table): # Removing all item-based logic on No Logic if logic_level == RequiredTactics.option_no_logic: - location_data = location_data._replace(rule=Location.access_rule) + location_data = location_data._replace(rule=Location.default_access_rule) location_table[i] = location_data # Generating Beat event locations if location_data.name.endswith((": Victory", ": Defeat")): diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index f11059a54ef5..598d06776c62 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -222,7 +222,7 @@ def assign_starter_items(world: World, excluded_items: Set[str], locked_location if starter_unit == StarterUnit.option_off: starter_mission_locations = [location.name for location in location_cache if location.parent_region.name == first_mission - and location.access_rule == Location.access_rule] + and location.access_rule == Location.default_access_rule] if not starter_mission_locations: # Force early unit if first mission is impossible without one starter_unit = StarterUnit.option_any_starter_unit diff --git a/worlds/stardew_valley/stardew_rule/rule_explain.py b/worlds/stardew_valley/stardew_rule/rule_explain.py index 2e2b9c959d7f..d8599ab4e716 100644 --- a/worlds/stardew_valley/stardew_rule/rule_explain.py +++ b/worlds/stardew_valley/stardew_rule/rule_explain.py @@ -127,7 +127,7 @@ def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[T access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] else: access_rules = [spot.access_rule, Reach(spot.parent_region.name, "Region", rule.player)] - elif spot.access_rule == Location.access_rule: + elif spot.access_rule == Location.default_access_rule: # Sometime locations just don't have an access rule and all the relevant logic is in the parent region. access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] @@ -140,7 +140,7 @@ def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[T access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] else: access_rules = [spot.access_rule, Reach(spot.parent_region.name, "Region", rule.player)] - elif spot.access_rule == Entrance.access_rule: + elif spot.access_rule == Entrance.default_access_rule: # Sometime entrances just don't have an access rule and all the relevant logic is in the parent region. access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] From 1ff33c87ca4e681ac0753748eedd45ce938a75e2 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 19:35:41 -0400 Subject: [PATCH 034/135] update to new api --- worlds/astalon/logic/custom_rules.py | 36 ++++++++++------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index fc9165777af6..2e12ec4b15d2 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -120,7 +120,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name.value}{count}{options})" - @dataclasses.dataclass(frozen=True) + @rule_builder.resolved_rule class Resolved(rule_builder.Has.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -206,7 +206,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @dataclasses.dataclass(frozen=True) + @rule_builder.resolved_rule class Resolved(rule_builder.HasAll.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -300,7 +300,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @dataclasses.dataclass(frozen=True) + @rule_builder.resolved_rule class Resolved(rule_builder.HasAny.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -334,7 +334,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.location_name.value}{options})" - @dataclasses.dataclass(frozen=True) + @rule_builder.resolved_rule class Resolved(rule_builder.CanReachLocation.Resolved): pass @@ -352,7 +352,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.region_name.value}{options})" - @dataclasses.dataclass(frozen=True) + @rule_builder.resolved_rule class Resolved(rule_builder.CanReachRegion.Resolved): pass @@ -364,15 +364,16 @@ class CanReachEntrance(rule_builder.Rule[AstalonWorld]): @override def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + parent_region = world.get_region(self.from_region.value) entrance = f"{self.from_region.value} -> {self.to_region.value}" - return self.Resolved(entrance, player=world.player) + return self.Resolved(entrance, parent_region.name, player=world.player) @override def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.from_region.value} -> {self.to_region.value}{options})" - @dataclasses.dataclass(frozen=True) + @rule_builder.resolved_rule class Resolved(rule_builder.CanReachEntrance.Resolved): pass @@ -478,9 +479,7 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": @rule_builder.custom_rule(AstalonWorld) -class HardLogic(rule_builder.Rule[AstalonWorld]): - child: "rule_builder.Rule[AstalonWorld]" - +class HardLogic(rule_builder.Wrapper[AstalonWorld]): @override def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": if world.options.difficulty.value == Difficulty.option_hard: @@ -489,29 +488,18 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": return self.Resolved(self.child.resolve(world), player=world.player) return rule_builder.False_.Resolved(player=world.player) - @override - def __str__(self) -> str: - return f"HardLogic[{self.child!s}]" - - @dataclasses.dataclass(frozen=True) - class Resolved(rule_builder.Rule.Resolved): - child: "rule_builder.Rule.Resolved" - rule_name: ClassVar[str] = "HardLogic" - + @rule_builder.resolved_rule + class Resolved(rule_builder.Wrapper.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child.test(state) @override def item_dependencies(self) -> dict[str, set[int]]: - deps = self.child.item_dependencies() + deps = super().item_dependencies() deps.setdefault(Events.FAKE_OOL_ITEM.value, set()).add(id(self)) return deps - @override - def indirect_regions(self) -> tuple[str, ...]: - return self.child.indirect_regions() - @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: "list[JSONMessagePart]" = [ From 41a0496e4050a0c82e707d096d5f7ea79f02807a Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 16 Jun 2025 20:21:49 -0400 Subject: [PATCH 035/135] update docs --- docs/rule builder.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 88552e039865..8ad9ef85acc0 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -43,6 +43,12 @@ When assigning the rule you must use the `set_rule` helper added by the rule mix 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. + +```python +self.create_entrance(from_region, to_region, 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. If you want a comparison that isn't equals, you can specify with the `operator` arguemnt. @@ -89,7 +95,7 @@ rule = Or( ## Defining custom rules -You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules and putting the `custom_rule` decorator on it. You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. You may need to also define an `item_dependencies` or `indirect_regions` function. +You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules and putting the `@custom_rule` decorator on it. You must provide or inherit a `Resolved` child class that defines an `_evaluate` method and has the `@resolved_rule` decorator on it. You may need to also define an `item_dependencies` or `indirect_regions` function. To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement: @@ -196,9 +202,9 @@ class MyRule(Rule): ## Rule explanations -Resolved rules have a default implementation for an `explain` message, which 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. +Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former 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 returns similar information but as a string. It is useful when debugging. -To implement a custom message with a custom rule, override the `explain` 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 @custom_rule(MyWorld) @@ -206,10 +212,31 @@ class MyRule(Rule): @resolved_rule class Resolved(Rule.Resolved): @override - def explain(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + 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": "You must be "}, - {"type": "color", "color": "green", "text": "THIS"}, + {"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" ``` From b0af7a1e403e7d29c5ba386ccfdf8852dfe5d481 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 18 Jun 2025 00:54:19 -0400 Subject: [PATCH 036/135] replace decorators with __init_subclass__ --- docs/rule builder.md | 39 ++++++++------- rule_builder.py | 114 +++++++++++++++++++++---------------------- 2 files changed, 76 insertions(+), 77 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 8ad9ef85acc0..a44ad571ee2d 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -95,17 +95,19 @@ rule = Or( ## Defining custom rules -You can create a custom rule by creating a class that inherits from `Rule` or any of the default rules and putting the `@custom_rule` decorator on it. You must provide or inherit a `Resolved` child class that defines an `_evaluate` method and has the `@resolved_rule` decorator on it. You may need to also define an `item_dependencies` or `indirect_regions` function. +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. + +You must provide or inherit a `Resolved` child class that defines an `_evaluate` method and has the `@dataclass(frozen=True)` decorator on it. You may need to also define an `item_dependencies` or `region_dependencies` function. To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement: ```python -@custom_rule(MyWorld) -class CanGoal(Rule[MyWorld]): +@dataclasses.dataclass() +class CanGoal(Rule["MyWorld"], game="My Game"): def _instantiate(self, world: "MyWorld") -> "Resolved": return self.Resolved(world.required_mcguffins, player=world.player) - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): goal: int @@ -121,9 +123,9 @@ class CanGoal(Rule[MyWorld]): If 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. ```python -@custom_rule(MyWorld) -class MyRule(Rule[MyWorld]): - @resolved_rule +@dataclasses.dataclass() +class MyRule(Rule["MyWorld"], game="My Game"): + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_name: str @@ -139,14 +141,14 @@ The default `Has`, `HasAll`, and `HasAny` rules define this function already. If your custom rule references other regions, it must define an `region_dependencies` function that returns a mapping of region names to the id of your rule. These will be combined to inform the caching system and indirect connections will be registered when you set this rule on an entrance. ```python -@custom_rule(MyWorld) -class MyRule(Rule[MyWorld]): - @resolved_rule +@dataclasses.dataclass() +class MyRule(Rule["MyWorld"], game="My Game"): + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): region_name: str @override - def indirect_regions(self) -> dict[str, set[int]]: + def region_dependencies(self) -> dict[str, set[int]]: return {self.region_name: {id(self)}} ``` @@ -181,9 +183,8 @@ The `And` and `Or` rules have a slightly different format: To define a custom format, override the `to_json` function: ```python -@custom_rule(MyWorld) -class MyRule(Rule): - def to_json(self) -> Any: +class MyRule(Rule, game="My Game"): + def to_json(self) -> Mapping[str, Any]: return { "rule": "my_rule", "custom_logic": [...] @@ -193,10 +194,9 @@ class MyRule(Rule): If your logic has been done in custom JSON first, you can define a `from_json` class method on your rules to parse it correctly: ```python -@custom_rule(MyWorld) -class MyRule(Rule): +class MyRule(Rule, game="My Game"): @classmethod - def from_json(cls, data: Any) -> Self: + def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(data.get("custom_logic")) ``` @@ -207,9 +207,8 @@ Resolved rules have a default implementation for `explain_json` and `explain_str To implement a custom message with a custom rule, override the `explain_json` and/or `explain_str` method on your `Resolved` class: ```python -@custom_rule(MyWorld) -class MyRule(Rule): - @resolved_rule +class MyRule(Rule, game="My Game"): + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": diff --git a/rule_builder.py b/rule_builder.py index f2306338932f..2a49fae2c4b0 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -2,9 +2,9 @@ import operator from collections import defaultdict from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, cast +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast -from typing_extensions import Never, Self, TypeVar, dataclass_transform, override +from typing_extensions import ClassVar, Never, Self, TypeVar, override from BaseClasses import Entrance @@ -17,13 +17,15 @@ World = object +class CustomRuleRegister: + custom_rules: ClassVar[dict[str, dict[str, type["Rule"]]]] = {} + + class RuleWorldMixin(World): rule_ids: "dict[int, Rule.Resolved]" rule_item_dependencies: dict[str, set[int]] rule_region_dependencies: dict[str, set[int]] - custom_rule_classes: "ClassVar[dict[str, type[Rule[Self]]]]" - def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} @@ -32,7 +34,7 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": - custom_rule_classes = getattr(cls, "custom_rule_classes", {}) + custom_rule_classes = CustomRuleRegister.custom_rules.get(cls.game, {}) if name not in DEFAULT_RULES and name not in custom_rule_classes: raise ValueError(f"Rule {name} not found") return custom_rule_classes.get(name) or DEFAULT_RULES[name] @@ -225,20 +227,6 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: _ = player_results.pop(rule_id, None) -TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 - - -@dataclass_transform() -def custom_rule(world_cls: "type[TWorld]", init: bool = True) -> "Callable[..., type[Rule[TWorld]]]": - def decorator(rule_cls: "type[Rule[TWorld]]") -> "type[Rule[TWorld]]": - if not hasattr(world_cls, "custom_rule_classes"): - world_cls.custom_rule_classes = {} - world_cls.custom_rule_classes[rule_cls.__name__] = rule_cls - return dataclasses.dataclass(init=init)(rule_cls) - - return decorator - - def _create_hash_fn(resolved_rule_cls: "type[Rule.Resolved]") -> "Callable[..., int]": def __hash__(self: "Rule.Resolved") -> int: return hash( @@ -253,20 +241,6 @@ def __hash__(self: "Rule.Resolved") -> int: return __hash__ -@dataclass_transform(frozen_default=True, field_specifiers=(dataclasses.field, dataclasses.Field)) -def resolved_rule( - resolved_rule_cls: "type[Rule.Resolved] | None" = None, -) -> "Callable[..., type[Rule.Resolved]] | type[Rule.Resolved]": - def decorator(resolved_rule_cls: "type[Rule.Resolved]") -> "type[Rule.Resolved]": - resolved_rule_cls.__hash__ = _create_hash_fn(resolved_rule_cls) - resolved_rule_cls.rule_name = resolved_rule_cls.__qualname__ - return dataclasses.dataclass(frozen=True)(resolved_rule_cls) - - if resolved_rule_cls is None: - return decorator - return decorator(resolved_rule_cls) - - Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] OPERATORS = { @@ -280,6 +254,7 @@ def decorator(resolved_rule_cls: "type[Rule.Resolved]") -> "type[Rule.Resolved]" } T = TypeVar("T") +TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 @dataclasses.dataclass(frozen=True) @@ -377,6 +352,22 @@ def __str__(self) -> str: options = f"options={self.options}" if self.options else "" return f"{self.__class__.__name__}({options})" + @classmethod + def __init_subclass__(cls, /, game: str) -> None: + # if "__dataclass_fields__" not in cls.__dict__: + # # ensure rule gets marked as a dataclass, but don't override manually dataclassed classes + # # is_dataclass will return True since all rule inherit from a dataclass + # dataclasses.dataclass(cls) + + if game != "Archipelago": + custom_rules = CustomRuleRegister.custom_rules.setdefault(game, {}) + if cls.__qualname__ in custom_rules: + raise TypeError(f"Rule {cls.__qualname__} has already been registered for game {game}") + custom_rules[cls.__qualname__] = cls # type: ignore + elif cls.__module__ != "rule_builder": + # TODO: test to make sure this works on frozen + raise TypeError("You cannot define custom rules for the base Archipelago world") + @dataclasses.dataclass(kw_only=True, frozen=True) class Resolved: """A resolved rule for a given world that can be used as an access rule""" @@ -447,12 +438,21 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"{self.rule_name}()" + def __init_subclass__(cls) -> None: + cls.__hash__ = _create_hash_fn(cls) + cls.rule_name = cls.__qualname__ + + # if "__dataclass_fields__" not in cls.__dict__: + # # ensure rule gets marked as a dataclass, but don't override manually dataclassed classes + # # is_dataclass will return True since all resolved rules inherit from a dataclass + # dataclasses.dataclass(frozen=True)(cls) + @dataclasses.dataclass() -class True_(Rule[TWorld]): +class True_(Rule[TWorld], game="Archipelago"): """A rule that always returns True""" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): always_true: ClassVar[bool] = True @@ -470,10 +470,10 @@ def __str__(self) -> str: @dataclasses.dataclass() -class False_(Rule[TWorld]): +class False_(Rule[TWorld], game="Archipelago"): """A rule that always returns False""" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): always_false: ClassVar[bool] = True @@ -491,7 +491,7 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) -class NestedRule(Rule[TWorld]): +class NestedRule(Rule[TWorld], game="Archipelago"): children: "tuple[Rule[TWorld], ...]" def __init__(self, *children: "Rule[TWorld]", options: "Iterable[OptionFilter[Any]]" = ()) -> None: @@ -522,7 +522,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({children}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" @@ -550,8 +550,8 @@ def region_dependencies(self) -> dict[str, set[int]]: @dataclasses.dataclass(init=False) -class And(NestedRule[TWorld]): - @resolved_rule +class And(NestedRule[TWorld], game="Archipelago"): + @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: @@ -582,8 +582,8 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) -class Or(NestedRule[TWorld]): - @resolved_rule +class Or(NestedRule[TWorld], game="Archipelago"): + @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: @@ -614,7 +614,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class Wrapper(Rule[TWorld]): +class Wrapper(Rule[TWorld], game="Archipelago"): """A rule that wraps another rule to provide extra logic or data""" child: "Rule[TWorld]" @@ -643,7 +643,7 @@ def from_json(cls, data: Mapping[str, Any]) -> Self: def __str__(self) -> str: return f"{self.__class__.__name__}[{self.child}]" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): child: "Rule.Resolved" @@ -682,7 +682,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class Has(Rule[TWorld]): +class Has(Rule[TWorld], game="Archipelago"): item_name: str count: int = 1 @@ -696,7 +696,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name}{count}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_name: str count: int = 1 @@ -733,7 +733,7 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) -class HasAll(Rule[TWorld]): +class HasAll(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has all of the given items""" item_names: tuple[str, ...] @@ -757,7 +757,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] @@ -802,7 +802,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class HasAny(Rule[TWorld]): +class HasAny(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least one of the given items""" item_names: tuple[str, ...] @@ -826,7 +826,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] @@ -871,7 +871,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class CanReachLocation(Rule[TWorld]): +class CanReachLocation(Rule[TWorld], game="Archipelago"): location_name: str """The name of the location to test access to""" @@ -898,7 +898,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.location_name}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): location_name: str parent_region_name: str @@ -933,7 +933,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class CanReachRegion(Rule[TWorld]): +class CanReachRegion(Rule[TWorld], game="Archipelago"): region_name: str """The name of the region to test access to""" @@ -946,7 +946,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.region_name}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): region_name: str @@ -978,7 +978,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class CanReachEntrance(Rule[TWorld]): +class CanReachEntrance(Rule[TWorld], game="Archipelago"): entrance_name: str """The name of the entrance to test access to""" @@ -1000,7 +1000,7 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.entrance_name}{options})" - @resolved_rule + @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): entrance_name: str parent_region_name: str From 336658f5dae0ff2ed8768f715564eb68027683fd Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 18 Jun 2025 01:14:03 -0400 Subject: [PATCH 037/135] ok now the frozen dataclass is automatic --- docs/rule builder.md | 8 +--- rule_builder.py | 87 ++++++++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 46 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index a44ad571ee2d..5785f28f7c3b 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -95,9 +95,9 @@ rule = Or( ## 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. +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 to 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 and has the `@dataclass(frozen=True)` decorator on it. You may need to also define an `item_dependencies` or `region_dependencies` function. +You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. You may need to also define an `item_dependencies` or `region_dependencies` function. To add a rule that checks if the user has enough mcguffins to goal, with a randomized requirement: @@ -107,7 +107,6 @@ class CanGoal(Rule["MyWorld"], game="My Game"): def _instantiate(self, world: "MyWorld") -> "Resolved": return self.Resolved(world.required_mcguffins, player=world.player) - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): goal: int @@ -125,7 +124,6 @@ If there are items that when collected will affect the result of your rule evalu ```python @dataclasses.dataclass() class MyRule(Rule["MyWorld"], game="My Game"): - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_name: str @@ -143,7 +141,6 @@ If your custom rule references other regions, it must define an `region_dependen ```python @dataclasses.dataclass() class MyRule(Rule["MyWorld"], game="My Game"): - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): region_name: str @@ -208,7 +205,6 @@ To implement a custom message with a custom rule, override the `explain_json` an ```python class MyRule(Rule, game="My Game"): - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": diff --git a/rule_builder.py b/rule_builder.py index 2a49fae2c4b0..4023ab0153ef 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -4,7 +4,7 @@ from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast -from typing_extensions import ClassVar, Never, Self, TypeVar, override +from typing_extensions import ClassVar, Never, Self, TypeVar, dataclass_transform, override from BaseClasses import Entrance @@ -17,10 +17,6 @@ World = object -class CustomRuleRegister: - custom_rules: ClassVar[dict[str, dict[str, type["Rule"]]]] = {} - - class RuleWorldMixin(World): rule_ids: "dict[int, Rule.Resolved]" rule_item_dependencies: dict[str, set[int]] @@ -227,20 +223,6 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: _ = player_results.pop(rule_id, None) -def _create_hash_fn(resolved_rule_cls: "type[Rule.Resolved]") -> "Callable[..., int]": - def __hash__(self: "Rule.Resolved") -> int: - return hash( - ( - self.__class__.__module__, - self.rule_name, - *[getattr(self, f.name) for f in dataclasses.fields(self)], - ) - ) - - __hash__.__qualname__ = f"{resolved_rule_cls.__qualname__}.{__hash__.__name__}" - return __hash__ - - Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] OPERATORS = { @@ -264,6 +246,42 @@ class OptionFilter(Generic[T]): operator: Operator = "eq" +def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> "Callable[..., int]": + def __hash__(self: "Rule.Resolved") -> int: + return hash( + ( + self.__class__.__module__, + self.rule_name, + *[getattr(self, f.name) for f in dataclasses.fields(self)], + ) + ) + + __hash__.__qualname__ = f"{resolved_rule_cls.__qualname__}.{__hash__.__name__}" + return __hash__ + + +@dataclass_transform(frozen_default=True, field_specifiers=(dataclasses.field, dataclasses.Field)) +class CustomRuleRegister(type): + custom_rules: ClassVar[dict[str, dict[str, type["Rule"]]]] = {} + rule_name: str = "Rule" + + def __new__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + /, + **kwds: dict[str, Any], + ) -> "CustomRuleRegister": + new_cls = super().__new__(cls, name, bases, namespace, **kwds) + new_cls.__hash__ = _create_hash_fn(new_cls) + rule_name = new_cls.__qualname__ + if rule_name.endswith(".Resolved"): + rule_name = rule_name[:-9] + new_cls.rule_name = rule_name + return dataclasses.dataclass(frozen=True)(new_cls) # type: ignore + + @dataclasses.dataclass() class Rule(Generic[TWorld]): """Base class for a static rule used to generate an access rule""" @@ -368,10 +386,11 @@ def __init_subclass__(cls, /, game: str) -> None: # TODO: test to make sure this works on frozen raise TypeError("You cannot define custom rules for the base Archipelago world") - @dataclasses.dataclass(kw_only=True, frozen=True) - class Resolved: + class Resolved(metaclass=CustomRuleRegister): """A resolved rule for a given world that can be used as an access rule""" + _: dataclasses.KW_ONLY + player: int """The player this rule is for""" @@ -438,21 +457,20 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"{self.rule_name}()" - def __init_subclass__(cls) -> None: - cls.__hash__ = _create_hash_fn(cls) - cls.rule_name = cls.__qualname__ + # def __init_subclass__(cls) -> None: + # cls.__hash__ = _create_hash_fn(cls) + # cls.rule_name = cls.__qualname__ - # if "__dataclass_fields__" not in cls.__dict__: - # # ensure rule gets marked as a dataclass, but don't override manually dataclassed classes - # # is_dataclass will return True since all resolved rules inherit from a dataclass - # dataclasses.dataclass(frozen=True)(cls) + # # if "__dataclass_fields__" not in cls.__dict__: + # # # ensure rule gets marked as a dataclass, but don't override manually dataclassed classes + # # # is_dataclass will return True since all resolved rules inherit from a dataclass + # # dataclasses.dataclass(frozen=True)(cls) @dataclasses.dataclass() class True_(Rule[TWorld], game="Archipelago"): """A rule that always returns True""" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): always_true: ClassVar[bool] = True @@ -473,7 +491,6 @@ def __str__(self) -> str: class False_(Rule[TWorld], game="Archipelago"): """A rule that always returns False""" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): always_false: ClassVar[bool] = True @@ -522,7 +539,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({children}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): children: "tuple[Rule.Resolved, ...]" @@ -551,7 +567,6 @@ def region_dependencies(self) -> dict[str, set[int]]: @dataclasses.dataclass(init=False) class And(NestedRule[TWorld], game="Archipelago"): - @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: @@ -583,7 +598,6 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) class Or(NestedRule[TWorld], game="Archipelago"): - @dataclasses.dataclass(frozen=True) class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: @@ -643,7 +657,6 @@ def from_json(cls, data: Mapping[str, Any]) -> Self: def __str__(self) -> str: return f"{self.__class__.__name__}[{self.child}]" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): child: "Rule.Resolved" @@ -696,7 +709,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name}{count}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_name: str count: int = 1 @@ -757,7 +769,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] @@ -826,7 +837,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): item_names: tuple[str, ...] @@ -898,7 +908,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.location_name}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): location_name: str parent_region_name: str @@ -946,7 +955,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.region_name}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): region_name: str @@ -1000,7 +1008,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.entrance_name}{options})" - @dataclasses.dataclass(frozen=True) class Resolved(Rule.Resolved): entrance_name: str parent_region_name: str From 83a446576001e549a6368df2afa19ee230dcd864 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 18 Jun 2025 01:16:56 -0400 Subject: [PATCH 038/135] update to latest api --- worlds/astalon/logic/custom_rules.py | 64 ++++++++++++--------------- worlds/astalon/logic/main_campaign.py | 4 +- worlds/astalon/world.py | 5 +-- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 2e12ec4b15d2..1a2b498e2fa1 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -5,6 +5,7 @@ import rule_builder +from ..constants import GAME_NAME from ..items import ( BlueDoor, Character, @@ -30,7 +31,6 @@ RandomizeSwitches, RandomizeWhiteKeys, ) -from ..world import AstalonWorld if TYPE_CHECKING: from collections.abc import Iterable @@ -41,6 +41,7 @@ from ..locations import LocationName from ..regions import RegionName + from ..world import AstalonWorld ITEM_DEPS: "dict[str, tuple[Character, ...]]" = { @@ -86,8 +87,8 @@ def _printjson_item(item: str, player: int, state: "CollectionState | None" = No return message -@rule_builder.custom_rule(AstalonWorld) -class Has(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class Has(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): item_name: "ItemName | Events" count: int = 1 @@ -120,7 +121,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.item_name.value}{count}{options})" - @rule_builder.resolved_rule class Resolved(rule_builder.Has.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -129,8 +129,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages -@rule_builder.custom_rule(AstalonWorld) -class HasAll(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class HasAll(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): item_names: "tuple[ItemName | Events, ...]" def __init__( @@ -206,7 +206,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @rule_builder.resolved_rule class Resolved(rule_builder.HasAll.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -223,8 +222,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages -@rule_builder.custom_rule(AstalonWorld) -class HasAny(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class HasAny(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): item_names: "tuple[ItemName | Events, ...]" def __init__( @@ -300,7 +299,6 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({items}{options})" - @rule_builder.resolved_rule class Resolved(rule_builder.HasAny.Resolved): @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -317,8 +315,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages -@rule_builder.custom_rule(AstalonWorld) -class CanReachLocation(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class CanReachLocation(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): location_name: "LocationName" @override @@ -334,13 +332,12 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.location_name.value}{options})" - @rule_builder.resolved_rule class Resolved(rule_builder.CanReachLocation.Resolved): pass -@rule_builder.custom_rule(AstalonWorld) -class CanReachRegion(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class CanReachRegion(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): region_name: "RegionName" @override @@ -352,13 +349,12 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.region_name.value}{options})" - @rule_builder.resolved_rule class Resolved(rule_builder.CanReachRegion.Resolved): pass -@rule_builder.custom_rule(AstalonWorld) -class CanReachEntrance(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class CanReachEntrance(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): from_region: "RegionName" to_region: "RegionName" @@ -373,13 +369,12 @@ def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.from_region.value} -> {self.to_region.value}{options})" - @rule_builder.resolved_rule class Resolved(rule_builder.CanReachEntrance.Resolved): pass @dataclasses.dataclass(init=False) -class ToggleRule(HasAll): +class ToggleRule(HasAll, game=GAME_NAME): option_cls: "ClassVar[type[Option[int]]]" otherwise: bool = False @@ -399,8 +394,8 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": return rule.resolve(world) -@rule_builder.custom_rule(AstalonWorld, init=False) -class HasWhite(ToggleRule): +@dataclasses.dataclass(init=False) +class HasWhite(ToggleRule, game=GAME_NAME): option_cls = RandomizeWhiteKeys def __init__( @@ -413,8 +408,8 @@ def __init__( self.otherwise = otherwise -@rule_builder.custom_rule(AstalonWorld, init=False) -class HasBlue(ToggleRule): +@dataclasses.dataclass(init=False) +class HasBlue(ToggleRule, game=GAME_NAME): option_cls = RandomizeBlueKeys def __init__( @@ -427,8 +422,8 @@ def __init__( self.otherwise = otherwise -@rule_builder.custom_rule(AstalonWorld, init=False) -class HasRed(ToggleRule): +@dataclasses.dataclass(init=False) +class HasRed(ToggleRule, game=GAME_NAME): option_cls = RandomizeRedKeys def __init__( @@ -441,8 +436,8 @@ def __init__( self.otherwise = otherwise -@rule_builder.custom_rule(AstalonWorld, init=False) -class HasSwitch(ToggleRule): +@dataclasses.dataclass(init=False) +class HasSwitch(ToggleRule, game=GAME_NAME): option_cls = RandomizeSwitches def __init__( @@ -455,8 +450,8 @@ def __init__( self.otherwise = otherwise -@rule_builder.custom_rule(AstalonWorld, init=False) -class HasElevator(HasAll): +@dataclasses.dataclass(init=False) +class HasElevator(HasAll, game=GAME_NAME): def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.OptionFilter[Any]]" = ()) -> None: super().__init__( KeyItem.ASCENDANT_KEY, @@ -465,8 +460,8 @@ def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.Opti ) -@rule_builder.custom_rule(AstalonWorld) -class HasGoal(rule_builder.Rule[AstalonWorld]): +@dataclasses.dataclass() +class HasGoal(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): @override def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": if world.options.goal.value != Goal.option_eye_hunt: @@ -478,8 +473,8 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": ) -@rule_builder.custom_rule(AstalonWorld) -class HardLogic(rule_builder.Wrapper[AstalonWorld]): +@dataclasses.dataclass() +class HardLogic(rule_builder.Wrapper["AstalonWorld"], game=GAME_NAME): @override def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": if world.options.difficulty.value == Difficulty.option_hard: @@ -488,7 +483,6 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": return self.Resolved(self.child.resolve(world), player=world.player) return rule_builder.False_.Resolved(player=world.player) - @rule_builder.resolved_rule class Resolved(rule_builder.Wrapper.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 5001c2de705e..c0a617b6d10b 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -54,7 +54,9 @@ red_off = [OptionFilter(RandomizeRedKeys, RandomizeRedKeys.option_false)] switch_off = [OptionFilter(RandomizeSwitches, RandomizeSwitches.option_false)] -can_uppies = HardLogic(True_(options=characters_off) | HasAny(Character.ARIAS, Character.BRAM, options=characters_on)) +can_uppies = HardLogic( + True_["AstalonWorld"](options=characters_off) | HasAny(Character.ARIAS, Character.BRAM, options=characters_on) +) can_extra_height = HasAny(Character.KYULI, KeyItem.BLOCK) | can_uppies can_extra_height_gold_block = HasAny(Character.KYULI, Character.ZEEK) | can_uppies can_combo_height = can_uppies & HasAll(KeyItem.BELL, KeyItem.BLOCK) diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index e3b0d2bc817b..6e6d1fa37863 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -38,6 +38,7 @@ location_name_to_id, location_table, ) +from .logic.main_campaign import MAIN_ENTRANCE_RULES, MAIN_LOCATION_RULES from .options import ApexElevator, AstalonOptions, Goal, RandomizeCharacters from .regions import RegionName, astalon_regions from .tracker import TRACKER_WORLD @@ -172,8 +173,6 @@ def generate_early(self) -> None: self.extra_gold_eyes = slot_data["extra_gold_eyes"] def create_location(self, name: str) -> AstalonLocation: - from .logic.main_campaign import MAIN_LOCATION_RULES - location_name = LocationName(name) data = location_table[name] region = self.get_region(data.region.value) @@ -185,8 +184,6 @@ def create_location(self, name: str) -> AstalonLocation: return location def create_regions(self) -> None: - from .logic.main_campaign import MAIN_ENTRANCE_RULES - for region_name in astalon_regions: region = Region(region_name.value, self.player, self.multiworld) self.multiworld.regions.append(region) From fd811f64ac93d0c2ceb8aa3afa5c2035366a5f42 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 18 Jun 2025 01:33:35 -0400 Subject: [PATCH 039/135] one more type fix for the road --- rule_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 4023ab0153ef..316a00723ff1 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -337,7 +337,7 @@ def to_json(self) -> Mapping[str, Any]: def from_json(cls, data: Mapping[str, Any]) -> Self: return cls(**data.get("args", {})) - def __and__(self, other: "Rule[TWorld]") -> "Rule[TWorld]": + def __and__(self, other: "Rule[Any]") -> "Rule[TWorld]": """Combines two rules into an And rule""" if isinstance(self, And): if isinstance(other, And): @@ -349,7 +349,7 @@ def __and__(self, other: "Rule[TWorld]") -> "Rule[TWorld]": return And(self, *other.children, options=other.options) return And(self, other) - def __or__(self, other: "Rule[TWorld]") -> "Rule[TWorld]": + def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": """Combines two rules into an Or rule""" if isinstance(self, Or): if isinstance(other, Or): From 348c9bf4dfbd02d06a8cced2c813c81bff771197 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 18 Jun 2025 01:34:21 -0400 Subject: [PATCH 040/135] type param no longer needed --- worlds/astalon/logic/main_campaign.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index c0a617b6d10b..5001c2de705e 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -54,9 +54,7 @@ red_off = [OptionFilter(RandomizeRedKeys, RandomizeRedKeys.option_false)] switch_off = [OptionFilter(RandomizeSwitches, RandomizeSwitches.option_false)] -can_uppies = HardLogic( - True_["AstalonWorld"](options=characters_off) | HasAny(Character.ARIAS, Character.BRAM, options=characters_on) -) +can_uppies = HardLogic(True_(options=characters_off) | HasAny(Character.ARIAS, Character.BRAM, options=characters_on)) can_extra_height = HasAny(Character.KYULI, KeyItem.BLOCK) | can_uppies can_extra_height_gold_block = HasAny(Character.KYULI, Character.ZEEK) | can_uppies can_combo_height = can_uppies & HasAll(KeyItem.BELL, KeyItem.BLOCK) From 2233329ef343d650cd4bb093b5787a3c2c3b7a76 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 20 Jun 2025 23:51:02 -0400 Subject: [PATCH 041/135] small fixes and caching tests --- rule_builder.py | 46 ++++-------- test/general/test_rule_builder.py | 113 ++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 316a00723ff1..a84ea3a0dc9a 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -21,12 +21,14 @@ class RuleWorldMixin(World): rule_ids: "dict[int, Rule.Resolved]" rule_item_dependencies: dict[str, set[int]] rule_region_dependencies: dict[str, set[int]] + rule_location_dependencies: dict[str, set[int]] def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} self.rule_item_dependencies = defaultdict(set) self.rule_region_dependencies = defaultdict(set) + self.rule_location_dependencies = defaultdict(set) @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": @@ -71,7 +73,9 @@ def create_entrance( if resolved_rule.always_false: return None - entrance = from_region.connect(to_region, rule=resolved_rule.test if resolved_rule else None) + entrance = from_region.connect(to_region) + if resolved_rule: + entrance.access_rule = resolved_rule if resolved_rule is not None: self.register_rule_connections(resolved_rule, entrance) return entrance @@ -194,7 +198,7 @@ def collect(self, state: "CollectionState", item: "Item") -> bool: if changed and getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] for rule_id in self.rule_item_dependencies[item.name]: - _ = player_results.pop(rule_id, None) + player_results.pop(rule_id, None) return changed @override @@ -204,13 +208,13 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: if changed and getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] for rule_id in self.rule_item_dependencies[item.name]: - _ = player_results.pop(rule_id, None) + player_results.pop(rule_id, None) # clear all region dependent caches as none can be trusted if changed and getattr(self, "rule_region_dependencies", None): for rule_ids in self.rule_region_dependencies.values(): for rule_id in rule_ids: - _ = state.rule_cache[self.player].pop(rule_id, None) + state.rule_cache[self.player].pop(rule_id, None) return changed @@ -220,7 +224,7 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: if getattr(self, "rule_region_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] for rule_id in self.rule_region_dependencies[region.name]: - _ = player_results.pop(rule_id, None) + player_results.pop(rule_id, None) Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] @@ -262,7 +266,7 @@ def __hash__(self: "Rule.Resolved") -> int: @dataclass_transform(frozen_default=True, field_specifiers=(dataclasses.field, dataclasses.Field)) class CustomRuleRegister(type): - custom_rules: ClassVar[dict[str, dict[str, type["Rule"]]]] = {} + custom_rules: ClassVar[dict[str, dict[str, type["Rule[Any]"]]]] = {} rule_name: str = "Rule" def __new__( @@ -272,14 +276,14 @@ def __new__( namespace: dict[str, Any], /, **kwds: dict[str, Any], - ) -> "CustomRuleRegister": + ) -> "type[CustomRuleRegister]": new_cls = super().__new__(cls, name, bases, namespace, **kwds) new_cls.__hash__ = _create_hash_fn(new_cls) rule_name = new_cls.__qualname__ if rule_name.endswith(".Resolved"): rule_name = rule_name[:-9] new_cls.rule_name = rule_name - return dataclasses.dataclass(frozen=True)(new_cls) # type: ignore + return dataclasses.dataclass(frozen=True)(new_cls) @dataclasses.dataclass() @@ -372,16 +376,11 @@ def __str__(self) -> str: @classmethod def __init_subclass__(cls, /, game: str) -> None: - # if "__dataclass_fields__" not in cls.__dict__: - # # ensure rule gets marked as a dataclass, but don't override manually dataclassed classes - # # is_dataclass will return True since all rule inherit from a dataclass - # dataclasses.dataclass(cls) - if game != "Archipelago": custom_rules = CustomRuleRegister.custom_rules.setdefault(game, {}) if cls.__qualname__ in custom_rules: raise TypeError(f"Rule {cls.__qualname__} has already been registered for game {game}") - custom_rules[cls.__qualname__] = cls # type: ignore + custom_rules[cls.__qualname__] = cls elif cls.__module__ != "rule_builder": # TODO: test to make sure this works on frozen raise TypeError("You cannot define custom rules for the base Archipelago world") @@ -406,16 +405,6 @@ class Resolved(metaclass=CustomRuleRegister): always_false: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" - @override - def __hash__(self) -> int: - return hash( - ( - self.__class__.__module__, - self.rule_name, - *[getattr(self, f.name) for f in dataclasses.fields(self)], - ) - ) - def _evaluate(self, state: "CollectionState") -> bool: """Calculate this rule's result with the given state""" ... @@ -457,15 +446,6 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: def __str__(self) -> str: return f"{self.rule_name}()" - # def __init_subclass__(cls) -> None: - # cls.__hash__ = _create_hash_fn(cls) - # cls.rule_name = cls.__qualname__ - - # # if "__dataclass_fields__" not in cls.__dict__: - # # # ensure rule gets marked as a dataclass, but don't override manually dataclassed classes - # # # is_dataclass will return True since all resolved rules inherit from a dataclass - # # dataclasses.dataclass(frozen=True)(cls) - @dataclasses.dataclass() class True_(Rule[TWorld], game="Archipelago"): diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index b2222fbeb274..52dca9b8787a 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -4,15 +4,29 @@ from typing_extensions import override +from BaseClasses import Item, ItemClassification, Location, Region from Options import Choice, PerGameCommonOptions, Toggle -from rule_builder import And, False_, Has, HasAll, HasAny, OptionFilter, Or, Rule, RuleWorldMixin, True_ +from rule_builder import ( + And, + CanReachLocation, + CanReachRegion, + False_, + Has, + HasAll, + HasAny, + OptionFilter, + Or, + Rule, + RuleWorldMixin, + True_, +) from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package from worlds.AutoWorld import World if TYPE_CHECKING: - from BaseClasses import MultiWorld + from BaseClasses import CollectionState, MultiWorld class ToggleOption(Toggle): @@ -32,13 +46,52 @@ class RuleBuilderOptions(PerGameCommonOptions): choice_option: ChoiceOption +GAME = "Rule Builder Test Game" +LOC_COUNT = 5 + + +class RuleBuilderItem(Item): + game: str = GAME + + +class RuleBuilderLocation(Location): + game: str = GAME + + class RuleBuilderWorld(RuleWorldMixin, World): - game: ClassVar[str] = "Rule Builder Test Game" - item_name_to_id: ClassVar[dict[str, int]] = {} - location_name_to_id: ClassVar[dict[str, int]] = {} + game: ClassVar[str] = GAME + item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} + location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} hidden: ClassVar[bool] = True - options_dataclass = RuleBuilderOptions + options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions options: RuleBuilderOptions # type: ignore + origin_region_name: str = "Region 1" + + @override + def create_regions(self) -> None: + region1 = Region("Region 1", self.player, self.multiworld) + region2 = Region("Region 2", self.player, self.multiworld) + region3 = Region("Region 3", self.player, self.multiworld) + self.multiworld.regions.extend([region1, region2, region3]) + + region1.add_locations({"Location 1": 1, "Location 2": 2}, RuleBuilderLocation) + region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) + region3.add_locations({"Location 5": 5}, RuleBuilderLocation) + + self.create_entrance(region1, region2, Has("Item 1")) + self.create_entrance(region1, region3, HasAny("Item 3", "Item 4")) + self.set_rule(self.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2")) + self.set_rule(self.get_location("Location 4"), HasAll("Item 2", "Item 3")) + self.set_rule(self.get_location("Location 5"), CanReachLocation("Location 4")) + + @override + def create_items(self) -> None: + for i in range(1, LOC_COUNT + 1): + self.create_item(f"Item {i}") + + @override + def create_item(self, name: str) -> "RuleBuilderItem": + return RuleBuilderItem(name, ItemClassification.progression, self.item_name_to_id[name], self.player) network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() @@ -173,3 +226,51 @@ def test_hashes(self) -> None: self.assertEqual(hash(rule1), hash(rule2)) self.assertNotEqual(hash(rule1), hash(rule3)) + + +class TestCaching(unittest.TestCase): + multiworld: "MultiWorld" + world: "RuleBuilderWorld" + state: "CollectionState" + + @override + def setUp(self) -> None: + self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + world = self.multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + self.world = world + self.state = self.multiworld.state + return super().setUp() + + def test_item_cache_busting(self) -> None: + entrance = self.world.get_entrance("Region 1 -> Region 2") + self.assertFalse(entrance.can_reach(self.state)) # populates cache + self.assertFalse(self.state.rule_cache[1][id(entrance.resolved_rule)]) + + self.state.collect(self.world.create_item("Item 1")) # clears cache, item directly needed + self.assertNotIn(id(entrance.resolved_rule), self.state.rule_cache[1]) + self.assertTrue(entrance.can_reach(self.state)) + + def test_region_cache_busting(self) -> None: + location = self.world.get_location("Location 2") + self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule + self.assertFalse(location.can_reach(self.state)) # populates cache + self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) + + self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for region 2 access + # cache gets cleared during the can_reach + self.assertTrue(location.can_reach(self.state)) + self.assertTrue(self.state.rule_cache[1][id(location.resolved_rule)]) + + # TODO: fix can reach location caching + @unittest.expectedFailure + def test_location_cache_busting(self) -> None: + location = self.world.get_location("Location 5") + self.state.collect(self.world.create_item("Item 1")) # access to region 2 + self.state.collect(self.world.create_item("Item 3")) # access to region 3 + self.assertFalse(location.can_reach(self.state)) # populates cache + self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) + + self.state.collect(self.world.create_item("Item 2")) # clears cache, item only needed for location 2 access + # self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) + self.assertTrue(location.can_reach(self.state)) From c7a30d9ab5ce2f602c1b52ca4297ed401a3b965e Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 21 Jun 2025 00:24:08 -0400 Subject: [PATCH 042/135] play nicer with tests --- test/general/test_rule_builder.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 52dca9b8787a..728dec81e0c6 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -47,7 +47,8 @@ class RuleBuilderOptions(PerGameCommonOptions): GAME = "Rule Builder Test Game" -LOC_COUNT = 5 +LOC_COUNT = 15 +PROG_COUNT = 5 class RuleBuilderItem(Item): @@ -60,7 +61,9 @@ class RuleBuilderLocation(Location): class RuleBuilderWorld(RuleWorldMixin, World): game: ClassVar[str] = GAME - item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} + item_name_to_id: ClassVar[dict[str, int]] = {"Filler": PROG_COUNT + 1} | { + f"Item {i}": i for i in range(1, PROG_COUNT + 1) + } location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} hidden: ClassVar[bool] = True options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions @@ -84,14 +87,20 @@ def create_regions(self) -> None: self.set_rule(self.get_location("Location 4"), HasAll("Item 2", "Item 3")) self.set_rule(self.get_location("Location 5"), CanReachLocation("Location 4")) + for i in range(PROG_COUNT + 1, LOC_COUNT + 1): + region1.locations.append(RuleBuilderLocation(self.player, f"Location {i}", i, region1)) + @override def create_items(self) -> None: - for i in range(1, LOC_COUNT + 1): - self.create_item(f"Item {i}") + for i in range(1, PROG_COUNT + 1): + self.multiworld.itempool.append(self.create_item(f"Item {i}")) + for _ in range(LOC_COUNT - PROG_COUNT): + self.multiworld.itempool.append(self.create_item("Filler")) @override def create_item(self, name: str) -> "RuleBuilderItem": - return RuleBuilderItem(name, ItemClassification.progression, self.item_name_to_id[name], self.player) + classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression + return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() From afbd08953d9ec377edea66d314d2e622d07e733a Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 21 Jun 2025 00:59:13 -0400 Subject: [PATCH 043/135] ok actually fix the tests --- test/general/test_rule_builder.py | 72 ++++++++++++++----------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 728dec81e0c6..fae73a51e9be 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -47,8 +47,7 @@ class RuleBuilderOptions(PerGameCommonOptions): GAME = "Rule Builder Test Game" -LOC_COUNT = 15 -PROG_COUNT = 5 +LOC_COUNT = 5 class RuleBuilderItem(Item): @@ -59,49 +58,24 @@ class RuleBuilderLocation(Location): game: str = GAME -class RuleBuilderWorld(RuleWorldMixin, World): +class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] game: ClassVar[str] = GAME - item_name_to_id: ClassVar[dict[str, int]] = {"Filler": PROG_COUNT + 1} | { - f"Item {i}": i for i in range(1, PROG_COUNT + 1) - } + item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} hidden: ClassVar[bool] = True options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions - options: RuleBuilderOptions # type: ignore + options: RuleBuilderOptions # type: ignore # pyright: ignore[reportIncompatibleVariableOverride] origin_region_name: str = "Region 1" - @override - def create_regions(self) -> None: - region1 = Region("Region 1", self.player, self.multiworld) - region2 = Region("Region 2", self.player, self.multiworld) - region3 = Region("Region 3", self.player, self.multiworld) - self.multiworld.regions.extend([region1, region2, region3]) - - region1.add_locations({"Location 1": 1, "Location 2": 2}, RuleBuilderLocation) - region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) - region3.add_locations({"Location 5": 5}, RuleBuilderLocation) - - self.create_entrance(region1, region2, Has("Item 1")) - self.create_entrance(region1, region3, HasAny("Item 3", "Item 4")) - self.set_rule(self.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2")) - self.set_rule(self.get_location("Location 4"), HasAll("Item 2", "Item 3")) - self.set_rule(self.get_location("Location 5"), CanReachLocation("Location 4")) - - for i in range(PROG_COUNT + 1, LOC_COUNT + 1): - region1.locations.append(RuleBuilderLocation(self.player, f"Location {i}", i, region1)) - - @override - def create_items(self) -> None: - for i in range(1, PROG_COUNT + 1): - self.multiworld.itempool.append(self.create_item(f"Item {i}")) - for _ in range(LOC_COUNT - PROG_COUNT): - self.multiworld.itempool.append(self.create_item("Filler")) - @override def create_item(self, name: str) -> "RuleBuilderItem": classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) + @override + def get_filler_item_name(self) -> str: + return "Filler" + network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() @@ -143,8 +117,8 @@ def test_simplify(self) -> None: class TestOptions(unittest.TestCase): - multiworld: "MultiWorld" - world: "RuleBuilderWorld" + multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] + world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] @override def setUp(self) -> None: @@ -238,9 +212,10 @@ def test_hashes(self) -> None: class TestCaching(unittest.TestCase): - multiworld: "MultiWorld" - world: "RuleBuilderWorld" - state: "CollectionState" + multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] + world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] + state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + player: int = 1 @override def setUp(self) -> None: @@ -249,6 +224,25 @@ def setUp(self) -> None: assert isinstance(world, RuleBuilderWorld) self.world = world self.state = self.multiworld.state + + region1 = Region("Region 1", self.player, self.multiworld) + region2 = Region("Region 2", self.player, self.multiworld) + region3 = Region("Region 3", self.player, self.multiworld) + self.multiworld.regions.extend([region1, region2, region3]) + + region1.add_locations({"Location 1": 1, "Location 2": 2}, RuleBuilderLocation) + region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) + region3.add_locations({"Location 5": 5}, RuleBuilderLocation) + + world.create_entrance(region1, region2, Has("Item 1")) + world.create_entrance(region1, region3, HasAny("Item 3", "Item 4")) + world.set_rule(world.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2")) + world.set_rule(world.get_location("Location 4"), HasAll("Item 2", "Item 3")) + world.set_rule(world.get_location("Location 5"), CanReachLocation("Location 4")) + + for i in range(1, LOC_COUNT + 1): + self.multiworld.itempool.append(world.create_item(f"Item {i}")) + return super().setUp() def test_item_cache_busting(self) -> None: From 9ad12450292d8d727871834d50a2eff8ce1d298c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 21 Jun 2025 19:08:24 -0400 Subject: [PATCH 044/135] add item_mapping for faris --- rule_builder.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index a84ea3a0dc9a..47db8e4830b3 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -23,6 +23,11 @@ class RuleWorldMixin(World): rule_region_dependencies: dict[str, set[int]] rule_location_dependencies: dict[str, set[int]] + item_mapping: ClassVar[dict[str, str]] = {} + """A mapping of actual item name to logical item name. + Useful when there are multiple versions of a collected item but the logic only uses one. For example: + item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}""" + def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} @@ -197,8 +202,11 @@ def collect(self, state: "CollectionState", item: "Item") -> bool: changed = super().collect(state, item) if changed and getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] - for rule_id in self.rule_item_dependencies[item.name]: + mapped_name = self.item_mapping.get(item.name, "") + rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] + for rule_id in rule_ids: player_results.pop(rule_id, None) + return changed @override @@ -207,7 +215,9 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: if changed and getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] - for rule_id in self.rule_item_dependencies[item.name]: + mapped_name = self.item_mapping.get(item.name, "") + rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] + for rule_id in rule_ids: player_results.pop(rule_id, None) # clear all region dependent caches as none can be trusted From 0ff085a47d1f24cd9cb336ecf6c0113dfa3f8387 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 22 Jun 2025 17:20:14 -0400 Subject: [PATCH 045/135] add more state helpers as rules --- rule_builder.py | 438 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 415 insertions(+), 23 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 47db8e4830b3..afcf98f2ea6d 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -454,7 +454,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: @override def __str__(self) -> str: - return f"{self.rule_name}()" + return self.rule_name @dataclasses.dataclass() @@ -713,7 +713,8 @@ def item_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] + verb = "Missing " if state and not self.test(state) else "Has " + messages: list[JSONMessagePart] = [{"type": "text", "text": verb}] if self.count > 1: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) messages.append({"type": "text", "text": "x "}) @@ -748,9 +749,9 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: - return True_[TWorld]().resolve(world) + return True_().resolve(world) if len(self.item_names) == 1: - return Has[TWorld](self.item_names[0]).resolve(world) + return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @override @@ -772,15 +773,46 @@ def item_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [ - {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": "all"}, + messages: list[JSONMessagePart] = [] + if state is None: + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": ")"}) + return messages + + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + messages = [ + {"type": "text", "text": "Has " if not missing else "Missing "}, + {"type": "color", "color": "cyan", "text": "all" if not missing else "some"}, {"type": "text", "text": " of ("}, ] - for i, item in enumerate(self.item_names): - if i > 0: - messages.append({"type": "text", "text": ", "}) - messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + if found: + messages.append({"type": "text", "text": "Found: "}) + for i, item in enumerate(found): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} + ) + if missing: + messages.append({"type": "text", "text": "; "}) + + if missing: + messages.append({"type": "text", "text": "Missing: "}) + for i, item in enumerate(missing): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} + ) messages.append({"type": "text", "text": ")"}) return messages @@ -816,9 +848,9 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: - return True_[TWorld]().resolve(world) + return True_().resolve(world) if len(self.item_names) == 1: - return Has[TWorld](self.item_names[0]).resolve(world) + return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @override @@ -840,15 +872,46 @@ def item_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [ - {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": "any"}, + messages: list[JSONMessagePart] = [] + if state is None: + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "any"}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": ")"}) + return messages + + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + messages = [ + {"type": "text", "text": "Has " if found else "Missing "}, + {"type": "color", "color": "cyan", "text": "some" if found else "all"}, {"type": "text", "text": " of ("}, ] - for i, item in enumerate(self.item_names): - if i > 0: - messages.append({"type": "text", "text": ", "}) - messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + if found: + messages.append({"type": "text", "text": "Found: "}) + for i, item in enumerate(found): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} + ) + if missing: + messages.append({"type": "text", "text": "; "}) + + if missing: + messages.append({"type": "text", "text": "Missing: "}) + for i, item in enumerate(missing): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} + ) messages.append({"type": "text", "text": ")"}) return messages @@ -870,6 +933,317 @@ def __str__(self) -> str: return f"Has all of ({items})" +@dataclasses.dataclass() +class HasAllCounts(Rule[TWorld], game="Archipelago"): + """A rule that checks if the player has all of the specified counts of the given items""" + + item_counts: dict[str, int] + """A mapping of item name to count to check for""" + + @override + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + if len(self.item_counts) == 0: + return True_().resolve(world) + if len(self.item_counts) == 1: + item = next(iter(self.item_counts)) + return Has(item, self.item_counts[item]).resolve(world) + return self.Resolved(self.item_counts, player=world.player) + + @override + def __str__(self) -> str: + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + + class Resolved(Rule.Resolved): + item_counts: dict[str, int] + + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_all_counts(self.item_counts, self.player) + + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.item_counts} + + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [] + if state is None: + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + ] + for i, (item, count) in enumerate(self.item_counts.items()): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": f" x{count}"}) + messages.append({"type": "text", "text": ")"}) + return messages + + found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + messages = [ + {"type": "text", "text": "Has " if not missing else "Missing "}, + {"type": "color", "color": "cyan", "text": "all" if not missing else "some"}, + {"type": "text", "text": " of ("}, + ] + if found: + messages.append({"type": "text", "text": "Found: "}) + for i, (item, count) in enumerate(found): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} + ) + messages.append({"type": "text", "text": f" x{count}"}) + if missing: + messages.append({"type": "text", "text": "; "}) + + if missing: + messages.append({"type": "text", "text": "Missing: "}) + for i, (item, count) in enumerate(missing): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} + ) + messages.append({"type": "text", "text": f" x{count}"}) + messages.append({"type": "text", "text": ")"}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + prefix = "Has all" if self.test(state) else "Missing some" + found_str = f"Found: {', '.join([f'{item} x{count}' for item, count in found])}" if found else "" + missing_str = f"Missing: {', '.join([f'{item} x{count}' for item, count in missing])}" if missing else "" + infix = "; " if found and missing else "" + return f"{prefix} of ({found_str}{infix}{missing_str})" + + @override + def __str__(self) -> str: + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + return f"Has all of ({items})" + + +@dataclasses.dataclass() +class HasAnyCount(Rule[TWorld], game="Archipelago"): + """A rule that checks if the player has any of the specified counts of the given items""" + + item_counts: dict[str, int] + """A mapping of item name to count to check for""" + + @override + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + if len(self.item_counts) == 0: + return True_().resolve(world) + if len(self.item_counts) == 1: + item = next(iter(self.item_counts)) + return Has(item, self.item_counts[item]).resolve(world) + return self.Resolved(self.item_counts, player=world.player) + + @override + def __str__(self) -> str: + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + + class Resolved(Rule.Resolved): + item_counts: dict[str, int] + + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_any_count(self.item_counts, self.player) + + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.item_counts} + + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [] + if state is None: + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "any"}, + {"type": "text", "text": " of ("}, + ] + for i, (item, count) in enumerate(self.item_counts.items()): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": f" x{count}"}) + messages.append({"type": "text", "text": ")"}) + return messages + + found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + messages = [ + {"type": "text", "text": "Has " if found else "Missing "}, + {"type": "color", "color": "cyan", "text": "some" if found else "all"}, + {"type": "text", "text": " of ("}, + ] + if found: + messages.append({"type": "text", "text": "Found: "}) + for i, (item, count) in enumerate(found): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} + ) + messages.append({"type": "text", "text": f" x{count}"}) + if missing: + messages.append({"type": "text", "text": "; "}) + + if missing: + messages.append({"type": "text", "text": "Missing: "}) + for i, (item, count) in enumerate(missing): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} + ) + messages.append({"type": "text", "text": f" x{count}"}) + messages.append({"type": "text", "text": ")"}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + prefix = "Has some" if self.test(state) else "Missing all" + found_str = f"Found: {', '.join([f'{item} x{count}' for item, count in found])}" if found else "" + missing_str = f"Missing: {', '.join([f'{item} x{count}' for item, count in missing])}" if missing else "" + infix = "; " if found and missing else "" + return f"{prefix} of ({found_str}{infix}{missing_str})" + + @override + def __str__(self) -> str: + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + return f"Has any of ({items})" + + +@dataclasses.dataclass() +class HasFromList(Rule[TWorld], game="Archipelago"): + """A rule that checks if the player has at least `count` of the given items""" + + item_names: tuple[str, ...] + """A tuple of item names to check for""" + + count: int = 1 + """The number of items the player needs to have""" + + def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + super().__init__(options=options) + self.item_names = item_names + + @override + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + if len(self.item_names) < self.count: + return False_().resolve(world) + if len(self.item_names) == 0: + return True_().resolve(world) + if len(self.item_names) == 1: + return Has(self.item_names[0]).resolve(world) + return self.Resolved(self.item_names, self.count, player=world.player) + + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}, count={self.count}{options})" + + class Resolved(Rule.Resolved): + item_names: tuple[str, ...] + count: int = 1 + + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_from_list(self.item_names, self.player, self.count) + + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.item_names} + + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [] + if state is None: + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": str(self.count)}, + {"type": "text", "text": " of ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": ")"}) + return messages + + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": f"{len(found)}/{self.count}"}, + {"type": "text", "text": " of ("}, + ] + if found: + messages.append({"type": "text", "text": "Found: "}) + for i, item in enumerate(found): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} + ) + if missing: + messages.append({"type": "text", "text": "; "}) + + if missing: + messages.append({"type": "text", "text": "Missing: "}) + for i, item in enumerate(missing): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} + ) + messages.append({"type": "text", "text": ")"}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + found_str = f"Found: {', '.join(found)}" if found else "" + missing_str = f"Missing: {', '.join(missing)}" if missing else "" + infix = "; " if found and missing else "" + return f"Has {len(found)}/{self.count} of ({found_str}{infix}{missing_str})" + + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + return f"Has {self.count} of ({items})" + + +@dataclasses.dataclass() +class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): + """A rule that checks if the player has at least `count` of the given items, ignoring duplicates""" + + def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + super().__init__(options=options) + self.item_names: tuple[str, ...] = tuple(sorted(set(item_names))) + + @dataclasses.dataclass() class CanReachLocation(Rule[TWorld], game="Archipelago"): location_name: str @@ -914,8 +1288,14 @@ def region_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + if state is None: + verb = "Can reach" + elif self.test(state): + verb = "Reached" + else: + verb = "Cannot reach" return [ - {"type": "text", "text": "Reached Location "}, + {"type": "text", "text": f"{verb} location "}, {"type": "location_name", "text": self.location_name, "player": self.player}, ] @@ -958,8 +1338,14 @@ def region_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + if state is None: + verb = "Can reach" + elif self.test(state): + verb = "Reached" + else: + verb = "Cannot reach" return [ - {"type": "text", "text": "Reached Region "}, + {"type": "text", "text": f"{verb} region "}, {"type": "color", "color": "yellow", "text": self.region_name}, ] @@ -1014,8 +1400,14 @@ def region_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + if state is None: + verb = "Can reach" + elif self.test(state): + verb = "Reached" + else: + verb = "Cannot reach" return [ - {"type": "text", "text": "Reached Entrance "}, + {"type": "text", "text": f"{verb} entrance "}, {"type": "entrance_name", "text": self.entrance_name, "player": self.player}, ] From 71d5fb32781f002978db9900c0e81a3dc25a2599 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 22 Jun 2025 17:54:12 -0400 Subject: [PATCH 046/135] fix has from list rules --- rule_builder.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index afcf98f2ea6d..f842d0960782 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1141,9 +1141,10 @@ class HasFromList(Rule[TWorld], game="Archipelago"): count: int = 1 """The number of items the player needs to have""" - def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) - self.item_names = item_names + self.item_names = tuple(sorted(set(item_names))) + self.count = 1 @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": @@ -1239,9 +1240,15 @@ def __str__(self) -> str: class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of the given items, ignoring duplicates""" - def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) self.item_names: tuple[str, ...] = tuple(sorted(set(item_names))) + self.count: int = 1 + + class Resolved(HasFromList.Resolved): + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_from_list_unique(self.item_names, self.player, self.count) @dataclasses.dataclass() From 8484f6386b2270e967691cf5de03ade8f1013baf Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 24 Jun 2025 20:46:55 -0400 Subject: [PATCH 047/135] fix can reach location caching and add set completion condition --- docs/rule builder.md | 25 +++++++++++ rule_builder.py | 73 +++++++++++++++++++++++++++++++ test/general/test_rule_builder.py | 4 +- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 5785f28f7c3b..ef4a95fbfb3b 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -49,6 +49,14 @@ There is also a `create_entrance` helper that will resolve the rule, check if it self.create_entrance(from_region, to_region, rule) ``` +You can also set a rule for your world's completion condition: + +```python +self.set_completion_rule(rule) +``` + +If your rules use `CanReachLocation` or a custom rule that depends on locations, you must call `self.register_location_dependencies()` after all of your locations exist to setup the caching system. + ## 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. If you want a comparison that isn't equals, you can specify with the `operator` arguemnt. @@ -151,6 +159,23 @@ class MyRule(Rule["MyWorld"], game="My Game"): 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. These dependencies will be combined to inform the caching system. + +```python +@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. + ## JSON serialization The rule builder is intended to be written first in Python for optimization and type safety. To export the rules to a client or tracker, there is a default JSON serializer implementation for all rules. By default the rules will export with the following format: diff --git a/rule_builder.py b/rule_builder.py index f842d0960782..d40e55cfd1ac 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -18,10 +18,22 @@ class RuleWorldMixin(World): + """A World mixin that provides helpers for interacting with the rule builder""" + rule_ids: "dict[int, Rule.Resolved]" + """A mapping of ids to resolved rules""" + rule_item_dependencies: dict[str, set[int]] + """A mapping of item name to set of rule ids""" + rule_region_dependencies: dict[str, set[int]] + """A mapping of region name to set of rule ids""" + rule_location_dependencies: dict[str, set[int]] + """A mapping of location name to set of rule ids""" + + completion_rule: "Rule.Resolved | None" = None + """The resolved rule used for the completion condition of this world""" item_mapping: ClassVar[dict[str, str]] = {} """A mapping of actual item name to logical item name. @@ -37,6 +49,7 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": + """Returns the world-registered or default rule with the given name""" custom_rule_classes = CustomRuleRegister.custom_rules.get(cls.game, {}) if name not in DEFAULT_RULES and name not in custom_rule_classes: raise ValueError(f"Rule {name} not found") @@ -44,23 +57,43 @@ def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": @classmethod def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule[Self]": + """Create a rule instance from a json loaded mapping""" name = data.get("rule", "") rule_class = cls.get_rule_cls(name) return rule_class.from_json(data) def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": + """Returns a resolved rule registered with the caching system for this world""" resolved_rule = rule.resolve(self) for item_name, rule_ids in resolved_rule.item_dependencies().items(): self.rule_item_dependencies[item_name] |= rule_ids for region_name, rule_ids in resolved_rule.region_dependencies().items(): self.rule_region_dependencies[region_name] |= rule_ids + for location_name, rule_ids in resolved_rule.location_dependencies().items(): + self.rule_location_dependencies[location_name] |= rule_ids return resolved_rule def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: + """Register indirect connections for this entrance based on the rule's dependencies""" for indirect_region in resolved_rule.region_dependencies().keys(): self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) + def register_location_dependencies(self) -> None: + """Register all rules that depend on locations with that location's dependencies""" + for location_name, rule_ids in self.rule_location_dependencies.items(): + try: + location = self.get_location(location_name) + except KeyError: + continue + if location.resolved_rule is None: + continue + for item_name in location.resolved_rule.item_dependencies(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name in location.resolved_rule.region_dependencies(): + self.rule_region_dependencies[region_name] |= rule_ids + def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: + """Resolve and set a rule on a location or entrance""" resolved_rule = self.resolve_rule(rule) spot.access_rule = resolved_rule if self.explicit_indirect_conditions and isinstance(spot, Entrance): @@ -72,6 +105,7 @@ def create_entrance( to_region: "Region", rule: "Rule[Self] | None", ) -> "Entrance | None": + """Try to create an entrance between regions with the given rule, skipping it if the rule resolves to False""" resolved_rule = None if rule is not None: resolved_rule = self.resolve_rule(rule) @@ -85,7 +119,14 @@ def create_entrance( self.register_rule_connections(resolved_rule, entrance) return entrance + def set_completion_rule(self, rule: "Rule[Self]") -> None: + """Set the completion rule for this world""" + resolved_rule = self.resolve_rule(rule) + self.multiworld.completion_condition[self.player] = resolved_rule.test + self.completion_rule = resolved_rule + def simplify_rule(self, rule: "Rule.Resolved") -> "Rule.Resolved": + """Simplify and optimize a resolved rule""" if isinstance(rule, And.Resolved): return self._simplify_and(rule) if isinstance(rule, Or.Resolved): @@ -226,6 +267,12 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: for rule_id in rule_ids: state.rule_cache[self.player].pop(rule_id, None) + # clear all location dependent caches as they may have lost region access + if changed and getattr(self, "rule_location_dependencies", None): + for rule_ids in self.rule_location_dependencies.values(): + for rule_id in rule_ids: + state.rule_cache[self.player].pop(rule_id, None) + return changed @override @@ -444,6 +491,10 @@ def region_dependencies(self) -> dict[str, set[int]]: used for indirect connections and cache invalidation""" return {} + def location_dependencies(self) -> dict[str, set[int]]: + """Returns a mapping of location name to set of object ids, used for cache invalidation""" + return {} + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": """Returns a list of printJSON messages that explain the logic for this rule""" return [{"type": "text", "text": self.rule_name}] @@ -554,6 +605,17 @@ def region_dependencies(self) -> dict[str, set[int]]: combined_deps[region_name] = {id(self), *rules} return combined_deps + @override + def location_dependencies(self) -> dict[str, set[int]]: + combined_deps: dict[str, set[int]] = {} + for child in self.children: + for location_name, rules in child.location_dependencies().items(): + if location_name in combined_deps: + combined_deps[location_name] |= rules + else: + combined_deps[location_name] = {id(self), *rules} + return combined_deps + @dataclasses.dataclass(init=False) class And(NestedRule[TWorld], game="Archipelago"): @@ -668,6 +730,13 @@ def region_dependencies(self) -> dict[str, set[int]]: deps[region_name] = {id(self), *rules} return deps + @override + def location_dependencies(self) -> dict[str, set[int]]: + deps: dict[str, set[int]] = {} + for location_name, rules in self.child.location_dependencies().items(): + deps[location_name] = {id(self), *rules} + return deps + @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: "list[JSONMessagePart]" = [{"type": "text", "text": f"{self.rule_name} ["}] @@ -1293,6 +1362,10 @@ def region_dependencies(self) -> dict[str, set[int]]: return {self.parent_region_name: {id(self)}} return {} + @override + def location_dependencies(self) -> dict[str, set[int]]: + return {self.location_name: {id(self)}} + @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": if state is None: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index fae73a51e9be..94d4fd5aa5fa 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -243,6 +243,8 @@ def setUp(self) -> None: for i in range(1, LOC_COUNT + 1): self.multiworld.itempool.append(world.create_item(f"Item {i}")) + world.register_location_dependencies() + return super().setUp() def test_item_cache_busting(self) -> None: @@ -265,8 +267,6 @@ def test_region_cache_busting(self) -> None: self.assertTrue(location.can_reach(self.state)) self.assertTrue(self.state.rule_cache[1][id(location.resolved_rule)]) - # TODO: fix can reach location caching - @unittest.expectedFailure def test_location_cache_busting(self) -> None: location = self.world.get_location("Location 5") self.state.collect(self.world.create_item("Item 1")) # access to region 2 From 9dca0bac1826dd4e870aae34a44da986179570a8 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 24 Jun 2025 20:59:38 -0400 Subject: [PATCH 048/135] update api --- worlds/astalon/logic/main_campaign.py | 3 +++ worlds/astalon/world.py | 25 ++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 5001c2de705e..3040447dfd9d 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -7,6 +7,7 @@ Character, Crystal, Elevator, + Events, Eye, Face, KeyItem, @@ -1304,3 +1305,5 @@ L.CATA_CANDLE_DEV_ROOM: Or(Has(KeyItem.CLAW), HasSwitch(Switch.CATA_DEV_ROOM, otherwise=True)), L.CATA_CANDLE_PRISON: HasBlue(BlueDoor.CATA_PRISON_RIGHT, otherwise=True), } + +COMPLETION_RULE = Has(Events.VICTORY) diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 6e6d1fa37863..67a3deab903c 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -1,6 +1,6 @@ import logging from functools import cached_property -from typing import TYPE_CHECKING, Any, ClassVar, Final +from typing import TYPE_CHECKING, Any, ClassVar, Final, override from BaseClasses import Item, ItemClassification, Region, Tutorial from Options import OptionError @@ -38,7 +38,7 @@ location_name_to_id, location_table, ) -from .logic.main_campaign import MAIN_ENTRANCE_RULES, MAIN_LOCATION_RULES +from .logic.main_campaign import COMPLETION_RULE, MAIN_ENTRANCE_RULES, MAIN_LOCATION_RULES from .options import ApexElevator, AstalonOptions, Goal, RandomizeCharacters from .regions import RegionName, astalon_regions from .tracker import TRACKER_WORLD @@ -134,14 +134,15 @@ class AstalonWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultip cached_spheres: ClassVar[list[set["Location"]]] # UT integration - tracker_world: ClassVar = TRACKER_WORLD - ut_can_gen_without_yaml = True - glitches_item_name = Events.FAKE_OOL_ITEM.value + tracker_world: ClassVar[dict[str, Any]] = TRACKER_WORLD + ut_can_gen_without_yaml: ClassVar[bool] = True + glitches_item_name: ClassVar[str] = Events.FAKE_OOL_ITEM.value def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.starting_characters = [] + @override def generate_early(self) -> None: if self.options.randomize_characters == RandomizeCharacters.option_solo: self.starting_characters.append(self.random.choice(CHARACTERS)) @@ -183,6 +184,7 @@ def create_location(self, name: str) -> AstalonLocation: region.locations.append(location) return location + @override def create_regions(self) -> None: for region_name in astalon_regions: region = Region(region_name.value, self.player, self.multiworld) @@ -276,11 +278,9 @@ def create_regions(self) -> None: ) victory_location.place_locked_item(victory_item) victory_region.locations.append(victory_location) - self.multiworld.completion_condition[self.player] = lambda state: state.has( - Events.VICTORY.value, - self.player, - ) + self.set_completion_rule(COMPLETION_RULE) + @override def create_item(self, name: str) -> AstalonItem: if name == Events.FAKE_OOL_ITEM: return AstalonItem(name, ItemClassification.progression, None, self.player) @@ -302,6 +302,7 @@ def create_event(self, event: Events, location_name: LocationName) -> None: def create_trap(self) -> AstalonItem: return self.create_item(self.get_trap_item_name()) + @override def create_items(self) -> None: itempool: list[Item] = [] filler_items: list[Item] = [] @@ -414,6 +415,10 @@ def create_items(self) -> None: self.multiworld.itempool += itempool + filler_items + @override + def set_rules(self) -> None: + self.register_location_dependencies() + @cached_property def filler_item_names(self) -> tuple[str, ...]: items = list(filler_items) @@ -425,6 +430,7 @@ def filler_item_names(self) -> tuple[str, ...]: items.append(Key.RED.value) return tuple(items) + @override def get_filler_item_name(self) -> str: return self.random.choice(self.filler_item_names) @@ -444,6 +450,7 @@ def stage_modify_multidata(cls, *_) -> None: # Clean up all references in cached spheres after generation completes. del cls.cached_spheres + @override def fill_slot_data(self) -> dict[str, Any]: return { "version": VERSION, From d07e6c4739e41beff95bd2a2890bb5901ded7611 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 24 Jun 2025 21:17:25 -0400 Subject: [PATCH 049/135] fix import --- worlds/astalon/world.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 67a3deab903c..085e2d84fe55 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -1,6 +1,8 @@ import logging from functools import cached_property -from typing import TYPE_CHECKING, Any, ClassVar, Final, override +from typing import TYPE_CHECKING, Any, ClassVar, Final + +from typing_extensions import override from BaseClasses import Item, ItemClassification, Region, Tutorial from Options import OptionError @@ -45,7 +47,7 @@ if TYPE_CHECKING: from BaseClasses import Location, MultiWorld - from Options import Option + from Options import Option, PerGameCommonOptions # ██░░░██████░░███░░░███ @@ -95,8 +97,8 @@ def launch_client() -> None: class AstalonWebWorld(WebWorld): - theme = "stone" - tutorials = [ # noqa: RUF012 + theme: ClassVar[str] = "stone" + tutorials: list["Tutorial"] = [ # noqa: RUF012 Tutorial( tutorial_name="Setup Guide", description="A guide to setting up the Astalon randomizer.", @@ -118,15 +120,15 @@ class AstalonWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultip on a mission to save their village from impending doom! """ - game = GAME_NAME - web = AstalonWebWorld() - options_dataclass = AstalonOptions + game: ClassVar[str] = GAME_NAME + web: ClassVar["WebWorld"] = AstalonWebWorld() + options_dataclass: ClassVar[type["PerGameCommonOptions"]] = AstalonOptions options: AstalonOptions # type: ignore # pyright: ignore[reportIncompatibleVariableOverride] - item_name_groups = item_name_groups - location_name_groups = location_name_groups - item_name_to_id = item_name_to_id - location_name_to_id = location_name_to_id - required_client_version = (0, 6, 0) + item_name_groups: ClassVar[dict[str, set[str]]] = item_name_groups + location_name_groups: ClassVar[dict[str, set[str]]] = location_name_groups + item_name_to_id: ClassVar[dict[str, int]] = item_name_to_id + location_name_to_id: ClassVar[dict[str, int]] = location_name_to_id + required_client_version: tuple[int, int, int] = (0, 6, 0) starting_characters: "list[Character]" extra_gold_eyes: int = 0 @@ -397,7 +399,7 @@ def create_items(self) -> None: if remove_count > len(filler_items): raise OptionError( f"Astalon player {self.player_name} failed: No space for eye hunt. " - "Lower your eye hunt goal or enable candle randomizer." + + "Lower your eye hunt goal or enable candle randomizer." ) if remove_count == len(filler_items): From 1c669ec4ac10ea3528bc2834de2973830080aa7a Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 24 Jun 2025 21:33:34 -0400 Subject: [PATCH 050/135] also update client --- worlds/astalon/client.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/worlds/astalon/client.py b/worlds/astalon/client.py index 7f04b3df207b..62f7906269fd 100644 --- a/worlds/astalon/client.py +++ b/worlds/astalon/client.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: from BaseClasses import CollectionState, Entrance, Location, MultiWorld, Region from NetUtils import JSONMessagePart + from rule_builder import Rule - from .logic import RuleInstance from .world import AstalonWorld try: @@ -47,14 +47,14 @@ class TrackerGameContext(CommonContext, TrackerGameContextMixin): class AstalonCommandProcessor(ClientCommandProcessor): # type: ignore ctx: "AstalonClientContext" - def _print_rule(self, rule: "RuleInstance | None", state: "CollectionState") -> None: + def _print_rule(self, rule: "Rule.Resolved | None", state: "CollectionState") -> None: if rule: if self.ctx.ui: messages: list[JSONMessagePart] = [{"type": "text", "text": " "}] - messages.extend(rule.explain(state)) + messages.extend(rule.explain_json(state)) self.ctx.ui.print_json(messages) else: - logger.info(" " + rule.serialize()) + logger.info(" " + rule.explain_str(state)) else: if self.ctx.ui: self.ctx.ui.print_json( @@ -123,12 +123,10 @@ def _cmd_route(self, location_or_region: str = "") -> None: path.reverse() for p in path: if self.ctx.ui: - self.ctx.ui.print_json( - [{"type": "entrance_name", "text": p.name, "player": self.ctx.player_id}] - ) + self.ctx.ui.print_json([{"type": "entrance_name", "text": p.name, "player": self.ctx.player_id}]) else: logger.info(p.name) - self._print_rule(getattr(p.access_rule, "__self__", None), state) + self._print_rule(p.resolved_rule, state) if goal_location: if self.ctx.ui: @@ -144,7 +142,7 @@ def _cmd_route(self, location_or_region: str = "") -> None: ) else: logger.info(f"-> {goal_location.name}") - self._print_rule(getattr(goal_location.access_rule, "__self__", None), state) + self._print_rule(goal_location.resolved_rule, state) class AstalonClientContext(TrackerGameContext): From 1e58bc4aa7f7b5b635fe68436250115cb640bca8 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 25 Jun 2025 22:56:55 -0400 Subject: [PATCH 051/135] fix can reach entrance caching --- docs/rule builder.md | 21 +++++++- rule_builder.py | 84 +++++++++++++++++++++++++++++-- test/general/test_rule_builder.py | 20 ++++++-- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index ef4a95fbfb3b..08f421834ffb 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -55,7 +55,7 @@ You can also set a rule for your world's completion condition: self.set_completion_rule(rule) ``` -If your rules use `CanReachLocation` or a custom rule that depends on locations, you must call `self.register_location_dependencies()` after all of your locations exist to setup the caching system. +If your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. ## Restricting options @@ -105,7 +105,7 @@ rule = Or( 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 to 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`. You may need to also define an `item_dependencies` or `region_dependencies` function. +You must provide or inherit a `Resolved` child class that defines an `_evaluate` method. This class will automatically be converted into a frozen `dataclass`. You may need to also 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: @@ -176,6 +176,23 @@ class MyRule(Rule["MyWorld"], game="My Game"): 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. These dependencies will be combined to inform the caching system. + +```python +@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. + ## JSON serialization The rule builder is intended to be written first in Python for optimization and type safety. To export the rules to a client or tracker, there is a default JSON serializer implementation for all rules. By default the rules will export with the following format: diff --git a/rule_builder.py b/rule_builder.py index d40e55cfd1ac..f3294eda068b 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -32,6 +32,9 @@ class RuleWorldMixin(World): rule_location_dependencies: dict[str, set[int]] """A mapping of location name to set of rule ids""" + rule_entrance_dependencies: dict[str, set[int]] + """A mapping of entrance name to set of rule ids""" + completion_rule: "Rule.Resolved | None" = None """The resolved rule used for the completion condition of this world""" @@ -46,6 +49,7 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: self.rule_item_dependencies = defaultdict(set) self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) + self.rule_entrance_dependencies = defaultdict(set) @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": @@ -71,6 +75,8 @@ def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": self.rule_region_dependencies[region_name] |= rule_ids for location_name, rule_ids in resolved_rule.location_dependencies().items(): self.rule_location_dependencies[location_name] |= rule_ids + for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): + self.rule_entrance_dependencies[entrance_name] |= rule_ids return resolved_rule def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: @@ -78,7 +84,7 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E for indirect_region in resolved_rule.region_dependencies().keys(): self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) - def register_location_dependencies(self) -> None: + def register_dependencies(self) -> None: """Register all rules that depend on locations with that location's dependencies""" for location_name, rule_ids in self.rule_location_dependencies.items(): try: @@ -92,6 +98,18 @@ def register_location_dependencies(self) -> None: for region_name in location.resolved_rule.region_dependencies(): self.rule_region_dependencies[region_name] |= rule_ids + for entrance_name, rule_ids in self.rule_entrance_dependencies.items(): + try: + entrance = self.get_entrance(entrance_name) + except KeyError: + continue + if entrance.resolved_rule is None: + continue + for item_name in entrance.resolved_rule.item_dependencies(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name in entrance.resolved_rule.region_dependencies(): + self.rule_region_dependencies[region_name] |= rule_ids + def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: """Resolve and set a rule on a location or entrance""" resolved_rule = self.resolve_rule(rule) @@ -253,8 +271,10 @@ def collect(self, state: "CollectionState", item: "Item") -> bool: @override def remove(self, state: "CollectionState", item: "Item") -> bool: changed = super().remove(state, item) + if not changed: + return changed - if changed and getattr(self, "rule_item_dependencies", None): + if getattr(self, "rule_item_dependencies", None): player_results: dict[int, bool] = state.rule_cache[self.player] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] @@ -262,17 +282,23 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: player_results.pop(rule_id, None) # clear all region dependent caches as none can be trusted - if changed and getattr(self, "rule_region_dependencies", None): + if getattr(self, "rule_region_dependencies", None): for rule_ids in self.rule_region_dependencies.values(): for rule_id in rule_ids: state.rule_cache[self.player].pop(rule_id, None) # clear all location dependent caches as they may have lost region access - if changed and getattr(self, "rule_location_dependencies", None): + if getattr(self, "rule_location_dependencies", None): for rule_ids in self.rule_location_dependencies.values(): for rule_id in rule_ids: state.rule_cache[self.player].pop(rule_id, None) + # clear all entrance dependent caches as they may have lost region access + if getattr(self, "rule_entrance_dependencies", None): + for rule_ids in self.rule_entrance_dependencies.values(): + for rule_id in rule_ids: + state.rule_cache[self.player].pop(rule_id, None) + return changed @override @@ -495,6 +521,10 @@ def location_dependencies(self) -> dict[str, set[int]]: """Returns a mapping of location name to set of object ids, used for cache invalidation""" return {} + def entrance_dependencies(self) -> dict[str, set[int]]: + """Returns a mapping of entrance name to set of object ids, used for cache invalidation""" + return {} + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": """Returns a list of printJSON messages that explain the logic for this rule""" return [{"type": "text", "text": self.rule_name}] @@ -616,6 +646,17 @@ def location_dependencies(self) -> dict[str, set[int]]: combined_deps[location_name] = {id(self), *rules} return combined_deps + @override + def entrance_dependencies(self) -> dict[str, set[int]]: + combined_deps: dict[str, set[int]] = {} + for child in self.children: + for entrance_name, rules in child.entrance_dependencies().items(): + if entrance_name in combined_deps: + combined_deps[entrance_name] |= rules + else: + combined_deps[entrance_name] = {id(self), *rules} + return combined_deps + @dataclasses.dataclass(init=False) class And(NestedRule[TWorld], game="Archipelago"): @@ -737,6 +778,13 @@ def location_dependencies(self) -> dict[str, set[int]]: deps[location_name] = {id(self), *rules} return deps + @override + def entrance_dependencies(self) -> dict[str, set[int]]: + deps: dict[str, set[int]] = {} + for entrance_name, rules in self.child.entrance_dependencies().items(): + deps[entrance_name] = {id(self), *rules} + return deps + @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": messages: "list[JSONMessagePart]" = [{"type": "text", "text": f"{self.rule_name} ["}] @@ -1320,6 +1368,30 @@ def _evaluate(self, state: "CollectionState") -> bool: return state.has_from_list_unique(self.item_names, self.player, self.count) +@dataclasses.dataclass() +class HasGroup(Rule[TWorld], game="Archipelago"): + item_name_group: str + count: int = 1 + + # TODO + + class Resolved(Rule.Resolved): + item_name_group: str + count: int = 1 + + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_group(self.item_name_group, self.player, self.count) + + +@dataclasses.dataclass() +class HasGroupUnique(HasGroup[TWorld], game="Archipelago"): + class Resolved(HasGroup.Resolved): + @override + def _evaluate(self, state: "CollectionState") -> bool: + return state.has_group_unique(self.item_name_group, self.player, self.count) + + @dataclasses.dataclass() class CanReachLocation(Rule[TWorld], game="Archipelago"): location_name: str @@ -1478,6 +1550,10 @@ def region_dependencies(self) -> dict[str, set[int]]: return {self.parent_region_name: {id(self)}} return {} + @override + def entrance_dependencies(self) -> dict[str, set[int]]: + return {self.entrance_name: {id(self)}} + @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": if state is None: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 94d4fd5aa5fa..2d04c5a0d23a 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -8,6 +8,7 @@ from Options import Choice, PerGameCommonOptions, Toggle from rule_builder import ( And, + CanReachEntrance, CanReachLocation, CanReachRegion, False_, @@ -47,7 +48,7 @@ class RuleBuilderOptions(PerGameCommonOptions): GAME = "Rule Builder Test Game" -LOC_COUNT = 5 +LOC_COUNT = 6 class RuleBuilderItem(Item): @@ -230,7 +231,7 @@ def setUp(self) -> None: region3 = Region("Region 3", self.player, self.multiworld) self.multiworld.regions.extend([region1, region2, region3]) - region1.add_locations({"Location 1": 1, "Location 2": 2}, RuleBuilderLocation) + region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderLocation) region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) region3.add_locations({"Location 5": 5}, RuleBuilderLocation) @@ -239,11 +240,12 @@ def setUp(self) -> None: world.set_rule(world.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2")) world.set_rule(world.get_location("Location 4"), HasAll("Item 2", "Item 3")) world.set_rule(world.get_location("Location 5"), CanReachLocation("Location 4")) + world.set_rule(world.get_location("Location 6"), CanReachEntrance("Region 1 -> Region 2") & Has("Item 2")) for i in range(1, LOC_COUNT + 1): self.multiworld.itempool.append(world.create_item(f"Item {i}")) - world.register_location_dependencies() + world.register_dependencies() return super().setUp() @@ -275,5 +277,15 @@ def test_location_cache_busting(self) -> None: self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) self.state.collect(self.world.create_item("Item 2")) # clears cache, item only needed for location 2 access - # self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) + self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) + self.assertTrue(location.can_reach(self.state)) + + def test_entrance_cache_busting(self) -> None: + location = self.world.get_location("Location 6") + self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule + self.assertFalse(location.can_reach(self.state)) # populates cache + self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) + + self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for entrance access + self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) self.assertTrue(location.can_reach(self.state)) From 1e9adf348d68b390d44c0779e901dd805dbe7154 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 25 Jun 2025 23:32:18 -0400 Subject: [PATCH 052/135] implement HasGroup and HasGroupUnique --- docs/rule builder.md | 14 ++++++-- rule_builder.py | 84 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 08f421834ffb..a696d199d62f 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -16,7 +16,7 @@ class MyWorld(RuleWorldMixin, World): game = "My Game" ``` -The rule builder comes with a few by default: +The rule builder comes with a few rules by default: - `True_`: Always returns true - `False_`: Always returns false @@ -25,6 +25,12 @@ The rule builder comes with a few by default: - `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 @@ -37,6 +43,8 @@ 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 added by the rule mixin to correctly resolve and register the rule. ```python @@ -57,7 +65,7 @@ self.set_completion_rule(rule) If your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. -## Restricting options +### 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. If you want a comparison that isn't equals, you can specify with the `operator` arguemnt. @@ -193,7 +201,7 @@ class MyRule(Rule["MyWorld"], game="My Game"): The default `CanReachEntrance` rule defines this function already. -## JSON serialization +### JSON serialization The rule builder is intended to be written first in Python for optimization and type safety. To export the rules to a client or tracker, there is a default JSON serializer implementation for all rules. By default the rules will export with the following format: diff --git a/rule_builder.py b/rule_builder.py index f3294eda068b..bbe9e8b4ef41 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1298,7 +1298,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess messages = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": str(self.count)}, - {"type": "text", "text": " of ("}, + {"type": "text", "text": " items from ("}, ] for i, item in enumerate(self.item_names): if i > 0: @@ -1312,7 +1312,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess messages = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": f"{len(found)}/{self.count}"}, - {"type": "text", "text": " of ("}, + {"type": "text", "text": " items from ("}, ] if found: messages.append({"type": "text", "text": "Found: "}) @@ -1345,17 +1345,17 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: found_str = f"Found: {', '.join(found)}" if found else "" missing_str = f"Missing: {', '.join(missing)}" if missing else "" infix = "; " if found and missing else "" - return f"Has {len(found)}/{self.count} of ({found_str}{infix}{missing_str})" + return f"Has {len(found)}/{self.count} items from ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: items = ", ".join(self.item_names) - return f"Has {self.count} of ({items})" + return f"Has {self.count} items from ({items})" @dataclasses.dataclass() class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): - """A rule that checks if the player has at least `count` of the given items, ignoring duplicates""" + """A rule that checks if the player has at least `count` of the given items, ignoring duplicates of the same item""" def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) @@ -1370,27 +1370,99 @@ def _evaluate(self, state: "CollectionState") -> bool: @dataclasses.dataclass() class HasGroup(Rule[TWorld], game="Archipelago"): + """A rule that checks if the player has at least `count` of the items present in the specified item group""" + item_name_group: str + """The name of the item group containing the items""" + count: int = 1 + """The number of items the player needs to have""" + + @override + def _instantiate(self, world: "TWorld") -> "Resolved": + item_names = tuple(sorted(world.item_name_groups[self.item_name_group])) + return self.Resolved(self.item_name_group, item_names, self.count, player=world.player) - # TODO + @override + def __str__(self) -> str: + count = f", count={self.count}" if self.count > 1 else "" + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}(item_name_group={self.item_name_group}{count}{options})" class Resolved(Rule.Resolved): item_name_group: str + item_names: tuple[str, ...] count: int = 1 @override def _evaluate(self, state: "CollectionState") -> bool: return state.has_group(self.item_name_group, self.player, self.count) + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.item_names} + + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] + if state is None: + messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) + else: + count = state.count_group(self.item_name_group, self.player) + color = "green" if count >= self.count else "salmon" + messages.append({"type": "color", "color": color, "text": f"{count}/{self.count}"}) + messages.append({"type": "text", "text": " items from "}) + messages.append({"type": "color", "color": "cyan", "text": self.item_name_group}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + count = state.count_group(self.item_name_group, self.player) + return f"Has {count}/{self.count} items from {self.item_name_group}" + + @override + def __str__(self) -> str: + count = f"{self.count}x items" if self.count > 1 else "an item" + return f"Has {count} from {self.item_name_group}" + @dataclasses.dataclass() class HasGroupUnique(HasGroup[TWorld], game="Archipelago"): + """A rule that checks if the player has at least `count` of the items present + in the specified item group, ignoring duplicates of the same item""" + class Resolved(HasGroup.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: return state.has_group_unique(self.item_name_group, self.player, self.count) + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] + if state is None: + messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) + else: + count = state.count_group_unique(self.item_name_group, self.player) + color = "green" if count >= self.count else "salmon" + messages.append({"type": "color", "color": color, "text": f"{count}/{self.count}"}) + messages.append({"type": "text", "text": " unique items from "}) + messages.append({"type": "color", "color": "cyan", "text": self.item_name_group}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + count = state.count_group_unique(self.item_name_group, self.player) + return f"Has {count}/{self.count} unique items from {self.item_name_group}" + + @override + def __str__(self) -> str: + count = f"{self.count}x unique items" if self.count > 1 else "a unique item" + return f"Has {count} from {self.item_name_group}" + @dataclasses.dataclass() class CanReachLocation(Rule[TWorld], game="Archipelago"): From 03878cf8c40b478ad923e4d479c730249c333489 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 26 Jun 2025 00:46:54 -0400 Subject: [PATCH 053/135] add more tests and fix some bugs --- rule_builder.py | 63 +++++------ test/general/test_rule_builder.py | 182 +++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 38 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index bbe9e8b4ef41..ac7b486108d6 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1064,7 +1064,7 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) - return self.Resolved(self.item_counts, player=world.player) + return self.Resolved(tuple(self.item_counts.items()), player=world.player) @override def __str__(self) -> str: @@ -1073,15 +1073,17 @@ def __str__(self) -> str: return f"{self.__class__.__name__}({items}{options})" class Resolved(Rule.Resolved): - item_counts: dict[str, int] + item_counts: tuple[tuple[str, int], ...] @override def _evaluate(self, state: "CollectionState") -> bool: - return state.has_all_counts(self.item_counts, self.player) + # it will certainly be faster to reimplement has_all_counts here + # I'm leaving it for now so I can benchmark it later + return state.has_all_counts(dict(self.item_counts), self.player) @override def item_dependencies(self) -> dict[str, set[int]]: - return {item: {id(self)} for item in self.item_counts} + return {item: {id(self)} for item, _ in self.item_counts} @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -1092,7 +1094,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess {"type": "color", "color": "cyan", "text": "all"}, {"type": "text", "text": " of ("}, ] - for i, (item, count) in enumerate(self.item_counts.items()): + for i, (item, count) in enumerate(self.item_counts): if i > 0: messages.append({"type": "text", "text": ", "}) messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) @@ -1100,8 +1102,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess messages.append({"type": "text", "text": ")"}) return messages - found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] - missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts if (item, count) not in found] messages = [ {"type": "text", "text": "Has " if not missing else "Missing "}, {"type": "color", "color": "cyan", "text": "all" if not missing else "some"}, @@ -1135,8 +1137,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) - found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] - missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts if (item, count) not in found] prefix = "Has all" if self.test(state) else "Missing some" found_str = f"Found: {', '.join([f'{item} x{count}' for item, count in found])}" if found else "" missing_str = f"Missing: {', '.join([f'{item} x{count}' for item, count in missing])}" if missing else "" @@ -1145,17 +1147,14 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: @override def __str__(self) -> str: - items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts]) return f"Has all of ({items})" @dataclasses.dataclass() -class HasAnyCount(Rule[TWorld], game="Archipelago"): +class HasAnyCount(HasAllCounts[TWorld], game="Archipelago"): """A rule that checks if the player has any of the specified counts of the given items""" - item_counts: dict[str, int] - """A mapping of item name to count to check for""" - @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 0: @@ -1163,24 +1162,14 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) - return self.Resolved(self.item_counts, player=world.player) - - @override - def __str__(self) -> str: - items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({items}{options})" - - class Resolved(Rule.Resolved): - item_counts: dict[str, int] + return self.Resolved(tuple(self.item_counts.items()), player=world.player) + class Resolved(HasAllCounts.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: - return state.has_any_count(self.item_counts, self.player) - - @override - def item_dependencies(self) -> dict[str, set[int]]: - return {item: {id(self)} for item in self.item_counts} + # it will certainly be faster to reimplement has_all_counts here + # I'm leaving it for now so I can benchmark it later + return state.has_any_count(dict(self.item_counts), self.player) @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": @@ -1191,7 +1180,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess {"type": "color", "color": "cyan", "text": "any"}, {"type": "text", "text": " of ("}, ] - for i, (item, count) in enumerate(self.item_counts.items()): + for i, (item, count) in enumerate(self.item_counts): if i > 0: messages.append({"type": "text", "text": ", "}) messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) @@ -1199,8 +1188,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess messages.append({"type": "text", "text": ")"}) return messages - found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] - missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts if (item, count) not in found] messages = [ {"type": "text", "text": "Has " if found else "Missing "}, {"type": "color", "color": "cyan", "text": "some" if found else "all"}, @@ -1234,8 +1223,8 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) - found = [(item, count) for item, count in self.item_counts.items() if state.has(item, self.player, count)] - missing = [(item, count) for item, count in self.item_counts.items() if (item, count) not in found] + found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] + missing = [(item, count) for item, count in self.item_counts if (item, count) not in found] prefix = "Has some" if self.test(state) else "Missing all" found_str = f"Found: {', '.join([f'{item} x{count}' for item, count in found])}" if found else "" missing_str = f"Missing: {', '.join([f'{item} x{count}' for item, count in missing])}" if missing else "" @@ -1244,7 +1233,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: @override def __str__(self) -> str: - items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts]) return f"Has any of ({items})" @@ -1261,7 +1250,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"): def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) - self.count = 1 + self.count = count @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": @@ -1360,7 +1349,7 @@ class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) self.item_names: tuple[str, ...] = tuple(sorted(set(item_names))) - self.count: int = 1 + self.count: int = count class Resolved(HasFromList.Resolved): @override diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 2d04c5a0d23a..a83655651e1c 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -14,7 +14,13 @@ False_, Has, HasAll, + HasAllCounts, HasAny, + HasAnyCount, + HasFromList, + HasFromListUnique, + HasGroup, + HasGroupUnique, OptionFilter, Or, Rule, @@ -63,6 +69,7 @@ class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMu game: ClassVar[str] = GAME item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} + item_name_groups: ClassVar[dict[str, set[str]]] = {"Group 1": {"Item 1", "Item 2", "Item 3"}} hidden: ClassVar[bool] = True options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions options: RuleBuilderOptions # type: ignore # pyright: ignore[reportIncompatibleVariableOverride] @@ -203,7 +210,7 @@ def test_composition(self) -> None: class TestHashes(unittest.TestCase): - def test_hashes(self) -> None: + def test_and_hash(self) -> None: rule1 = And.Resolved((True_.Resolved(player=1),), player=1) rule2 = And.Resolved((True_.Resolved(player=1),), player=1) rule3 = Or.Resolved((True_.Resolved(player=1),), player=1) @@ -211,6 +218,15 @@ def test_hashes(self) -> None: self.assertEqual(hash(rule1), hash(rule2)) self.assertNotEqual(hash(rule1), hash(rule3)) + def test_has_all_hash(self) -> None: + multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + world = multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + + rule1 = HasAll("1", "2") + rule2 = HasAll("2", "2", "2", "1") + self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world))) + class TestCaching(unittest.TestCase): multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] @@ -289,3 +305,167 @@ def test_entrance_cache_busting(self) -> None: self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for entrance access self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) self.assertTrue(location.can_reach(self.state)) + + +class TestRules(unittest.TestCase): + multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] + world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] + state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + player: int = 1 + + @override + def setUp(self) -> None: + self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + world = self.multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + self.world = world + self.state = self.multiworld.state + + def test_true(self) -> None: + rule = True_() + resolved_rule = self.world.resolve_rule(rule) + self.assertTrue(resolved_rule.test(self.state)) + + def test_false(self) -> None: + rule = False_() + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has(self) -> None: + rule = Has("Item 1") + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + item = self.world.create_item("Item 1") + self.state.collect(item) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(item) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_all(self) -> None: + rule = HasAll("Item 1", "Item 2") + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + item1 = self.world.create_item("Item 1") + self.state.collect(item1) + self.assertFalse(resolved_rule.test(self.state)) + item2 = self.world.create_item("Item 2") + self.state.collect(item2) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(item1) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_any(self) -> None: + item_names = ("Item 1", "Item 2") + rule = HasAny(*item_names) + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + + for item_name in item_names: + item = self.world.create_item(item_name) + self.state.collect(item) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(item) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_all_counts(self) -> None: + rule = HasAllCounts({"Item 1": 1, "Item 2": 2}) + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + item1 = self.world.create_item("Item 1") + self.state.collect(item1) + self.assertFalse(resolved_rule.test(self.state)) + item2 = self.world.create_item("Item 2") + self.state.collect(item2) + self.assertFalse(resolved_rule.test(self.state)) + item2 = self.world.create_item("Item 2") + self.state.collect(item2) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(item2) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_any_count(self) -> None: + item_counts = {"Item 1": 1, "Item 2": 2} + rule = HasAnyCount(item_counts) + resolved_rule = self.world.resolve_rule(rule) + + for item_name, count in item_counts.items(): + item = self.world.create_item(item_name) + for _ in range(count): + self.assertFalse(resolved_rule.test(self.state)) + self.state.collect(item) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(item) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_from_list(self) -> None: + item_names = ("Item 1", "Item 2", "Item 3") + rule = HasFromList(*item_names, count=2) + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + + items: list[Item] = [] + for i, item_name in enumerate(item_names): + item = self.world.create_item(item_name) + self.state.collect(item) + items.append(item) + if i == 0: + self.assertFalse(resolved_rule.test(self.state)) + else: + self.assertTrue(resolved_rule.test(self.state)) + + for i in range(2): + self.state.remove(items[i]) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_from_list_unique(self) -> None: + item_names = ("Item 1", "Item 1", "Item 2") + rule = HasFromListUnique(*item_names, count=2) + resolved_rule = self.world.resolve_rule(rule) + self.assertFalse(resolved_rule.test(self.state)) + + items: list[Item] = [] + for i, item_name in enumerate(item_names): + item = self.world.create_item(item_name) + self.state.collect(item) + items.append(item) + if i < 2: + self.assertFalse(resolved_rule.test(self.state)) + else: + self.assertTrue(resolved_rule.test(self.state)) + + self.state.remove(items[0]) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(items[1]) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_group(self) -> None: + rule = HasGroup("Group 1", count=2) + resolved_rule = self.world.resolve_rule(rule) + + items: list[Item] = [] + for item_name in ("Item 1", "Item 2"): + self.assertFalse(resolved_rule.test(self.state)) + item = self.world.create_item(item_name) + self.state.collect(item) + items.append(item) + + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(items[0]) + self.assertFalse(resolved_rule.test(self.state)) + + def test_has_group_unique(self) -> None: + rule = HasGroupUnique("Group 1", count=2) + resolved_rule = self.world.resolve_rule(rule) + + items: list[Item] = [] + for item_name in ("Item 1", "Item 1", "Item 2"): + self.assertFalse(resolved_rule.test(self.state)) + item = self.world.create_item(item_name) + self.state.collect(item) + items.append(item) + + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(items[0]) + self.assertTrue(resolved_rule.test(self.state)) + self.state.remove(items[1]) + self.assertFalse(resolved_rule.test(self.state)) From 072959bb12273f84c526f2ccfc73ce8f37c2ada0 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 26 Jun 2025 22:36:46 -0400 Subject: [PATCH 054/135] Add name arg to create_entrance Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com> --- rule_builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rule_builder.py b/rule_builder.py index ac7b486108d6..252a38dad647 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -122,6 +122,7 @@ def create_entrance( from_region: "Region", to_region: "Region", rule: "Rule[Self] | None", + name: str | None = None, ) -> "Entrance | None": """Try to create an entrance between regions with the given rule, skipping it if the rule resolves to False""" resolved_rule = None @@ -130,7 +131,7 @@ def create_entrance( if resolved_rule.always_false: return None - entrance = from_region.connect(to_region) + entrance = from_region.connect(to_region, name) if resolved_rule: entrance.access_rule = resolved_rule if resolved_rule is not None: From 4bc1e06fb45712b6897aa488b5a373cfbfa9dfe1 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 29 Jun 2025 18:58:07 -0400 Subject: [PATCH 055/135] fix json dumping option filters --- rule_builder.py | 56 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 252a38dad647..6a88c301e369 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1,4 +1,5 @@ import dataclasses +import importlib import operator from collections import defaultdict from collections.abc import Iterable, Mapping @@ -7,11 +8,12 @@ from typing_extensions import ClassVar, Never, Self, TypeVar, dataclass_transform, override from BaseClasses import Entrance +from Options import Option if TYPE_CHECKING: from BaseClasses import CollectionState, Item, Location, MultiWorld, Region from NetUtils import JSONMessagePart - from Options import CommonOptions, Option + from Options import CommonOptions from worlds.AutoWorld import World else: World = object @@ -333,6 +335,32 @@ class OptionFilter(Generic[T]): value: T operator: Operator = "eq" + def to_json(self) -> dict[str, Any]: + return { + "option": f"{self.option.__module__}.{self.option.__name__}", + "value": self.value, + "operator": self.operator, + } + + @classmethod + def from_json(cls, data: dict[str, Any]) -> Self: + if "option" not in data or "value" not in data: + raise ValueError("Missing required value and/or option") + + option_path = data["option"] + try: + option_mod_name, option_cls_name = option_path.rsplit(".", 1) + option_module = importlib.import_module(option_mod_name) + option = getattr(option_module, option_cls_name, None) + except (ValueError, ImportError) as e: + raise ValueError(f"Cannot parse option '{option_path}'") from e + if option is None or not issubclass(option, Option): + raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass") + + value = data["value"] + operator = data.get("operator", "eq") + return cls(option=cast("type[Option[Any]]", option), value=value, operator=operator) + def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> "Callable[..., int]": def __hash__(self: "Rule.Resolved") -> int: @@ -410,14 +438,14 @@ def resolve(self, world: "TWorld") -> "Resolved": world.rule_ids[rule_hash] = instance return world.rule_ids[rule_hash] - def to_json(self) -> Mapping[str, Any]: + def to_json(self) -> dict[str, Any]: """Returns a JSON-serializable definition of this rule""" args = { field.name: getattr(self, field.name, None) for field in dataclasses.fields(self) if field.name != "options" } - args["options"] = [dataclasses.asdict(o) for o in self.options] return { "rule": self.__class__.__name__, + "options": [o.to_json() for o in self.options], "args": args, } @@ -593,12 +621,11 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return world.simplify_rule(self.Resolved(tuple(children), player=world.player)) @override - def to_json(self) -> Mapping[str, Any]: - return { - "rule": self.__class__.__name__, - "options": self.options, - "children": [c.to_json() for c in self.children], - } + def to_json(self) -> dict[str, Any]: + data = super().to_json() + del data["args"] + data["children"] = [c.to_json() for c in self.children] + return data @override @classmethod @@ -732,12 +759,11 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return self.Resolved(self.child.resolve(world), player=world.player) @override - def to_json(self) -> Mapping[str, Any]: - return { - "rule": self.__class__.__name__, - "options": self.options, - "child": self.child.to_json(), - } + def to_json(self) -> dict[str, Any]: + data = super().to_json() + del data["args"] + data["child"] = self.child.to_json() + return data @override @classmethod From f5f03ff2e3e1def8bf76a8dace50fede433ad3d1 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 30 Jun 2025 23:14:59 -0400 Subject: [PATCH 056/135] restructure and test serialization --- docs/rule builder.md | 105 +++++++++++++++++---- rule_builder.py | 140 +++++++++++++++++++-------- test/general/test_rule_builder.py | 152 +++++++++++++++++++++++++++++- 3 files changed, 338 insertions(+), 59 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index a696d199d62f..87952a05bb84 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -201,50 +201,115 @@ class MyRule(Rule["MyWorld"], game="My Game"): The default `CanReachEntrance` rule defines this function already. -### JSON serialization +## Serialization -The rule builder is intended to be written first in Python for optimization and type safety. To export the rules to a client or tracker, there is a default JSON serializer implementation for all rules. By default the rules will export with the following format: +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. -```json +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 { - "rule": "", + "rule": "Has", + "options": [], "args": { - "options": {...}, - "": // for each field the rule defines - } + "item_name": "Some item", + "count": 1, + }, } ``` -The `And` and `Or` rules have a slightly different 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: -```json +```python { "rule": "And", - "options": {...}, + "options": [], "children": [ - {} + ..., # each serialized rule ] } ``` -To define a custom format, override the `to_json` function: +A full example is as follows: ```python -class MyRule(Rule, game="My Game"): - def to_json(self) -> Mapping[str, Any]: +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: + +```python +class BasicLogicRule(Rule, game="My Game"): + items = ("one", "two") + + def to_dict(self) -> dict[str, Any]: + # Return whatever format works best for you return { - "rule": "my_rule", - "custom_logic": [...] + "logic": "basic", + "items": self.items, } ``` -If your logic has been done in custom JSON first, you can define a `from_json` 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 MyRule(Rule, game="My Game"): +class BasicLogicRule(Rule, game="My Game"): @classmethod - def from_json(cls, data: Mapping[str, Any]) -> Self: - return cls(data.get("custom_logic")) + def from_dict(cls, data: Mapping[str, Any]) -> Self: + items = data.get("items", ()) + return cls(*items) ``` ## Rule explanations diff --git a/rule_builder.py b/rule_builder.py index 6a88c301e369..c38c0cbff721 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -56,17 +56,14 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": """Returns the world-registered or default rule with the given name""" - custom_rule_classes = CustomRuleRegister.custom_rules.get(cls.game, {}) - if name not in DEFAULT_RULES and name not in custom_rule_classes: - raise ValueError(f"Rule {name} not found") - return custom_rule_classes.get(name) or DEFAULT_RULES[name] + return CustomRuleRegister.get_rule_cls(cls.game, name) @classmethod - def rule_from_json(cls, data: Mapping[str, Any]) -> "Rule[Self]": - """Create a rule instance from a json loaded mapping""" + 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_json(data) + return rule_class.from_dict(data, cls) def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": """Returns a resolved rule registered with the caching system for this world""" @@ -263,7 +260,7 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": def collect(self, state: "CollectionState", item: "Item") -> bool: changed = super().collect(state, item) if changed and getattr(self, "rule_item_dependencies", None): - player_results: dict[int, bool] = state.rule_cache[self.player] + player_results = state.rule_cache[self.player] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] for rule_id in rule_ids: @@ -277,8 +274,8 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: if not changed: return changed + player_results = state.rule_cache[self.player] if getattr(self, "rule_item_dependencies", None): - player_results: dict[int, bool] = state.rule_cache[self.player] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] for rule_id in rule_ids: @@ -288,19 +285,19 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: if getattr(self, "rule_region_dependencies", None): for rule_ids in self.rule_region_dependencies.values(): for rule_id in rule_ids: - state.rule_cache[self.player].pop(rule_id, None) + player_results.pop(rule_id, None) # clear all location dependent caches as they may have lost region access if getattr(self, "rule_location_dependencies", None): for rule_ids in self.rule_location_dependencies.values(): for rule_id in rule_ids: - state.rule_cache[self.player].pop(rule_id, None) + player_results.pop(rule_id, None) # clear all entrance dependent caches as they may have lost region access if getattr(self, "rule_entrance_dependencies", None): for rule_ids in self.rule_entrance_dependencies.values(): for rule_id in rule_ids: - state.rule_cache[self.player].pop(rule_id, None) + player_results.pop(rule_id, None) return changed @@ -308,7 +305,7 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: def reached_region(self, state: "CollectionState", region: "Region") -> None: super().reached_region(state, region) if getattr(self, "rule_region_dependencies", None): - player_results: dict[int, bool] = state.rule_cache[self.player] + player_results = state.rule_cache[self.player] for rule_id in self.rule_region_dependencies[region.name]: player_results.pop(rule_id, None) @@ -324,6 +321,14 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: "le": operator.le, "contains": operator.contains, } +operator_strings = { + "eq": "==", + "ne": "!=", + "gt": ">", + "lt": "<", + "ge": ">=", + "le": "<=", +} T = TypeVar("T") TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 @@ -335,7 +340,8 @@ class OptionFilter(Generic[T]): value: T operator: Operator = "eq" - def to_json(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, Any]: + """Returns a JSON compatible dict representation of this option filter""" return { "option": f"{self.option.__module__}.{self.option.__name__}", "value": self.value, @@ -343,7 +349,8 @@ def to_json(self) -> dict[str, Any]: } @classmethod - def from_json(cls, data: dict[str, Any]) -> Self: + def from_dict(cls, data: dict[str, Any]) -> Self: + """Returns a new OptionFilter instance from a dict representation""" if "option" not in data or "value" not in data: raise ValueError("Missing required value and/or option") @@ -361,6 +368,16 @@ def from_json(cls, data: dict[str, Any]) -> Self: operator = data.get("operator", "eq") return cls(option=cast("type[Option[Any]]", option), value=value, operator=operator) + @classmethod + def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> "tuple[OptionFilter[Any], ...]": + """Returns a tuple of OptionFilters instances from an iterable of dict representations""" + return tuple(cls.from_dict(o) for o in data) + + @override + def __str__(self) -> str: + op = operator_strings.get(self.operator, self.operator) + return f"{self.option.__name__} {op} {self.value}" + def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> "Callable[..., int]": def __hash__(self: "Rule.Resolved") -> int: @@ -378,8 +395,13 @@ def __hash__(self: "Rule.Resolved") -> int: @dataclass_transform(frozen_default=True, field_specifiers=(dataclasses.field, dataclasses.Field)) class CustomRuleRegister(type): + """A metaclass to contain world custom rules and automatically convert resolved rules to frozen dataclasses""" + custom_rules: ClassVar[dict[str, dict[str, type["Rule[Any]"]]]] = {} + """A mapping of game name to mapping of rule name to rule class""" + rule_name: str = "Rule" + """The string name of a rule, must be unique per game""" def __new__( cls, @@ -397,6 +419,14 @@ def __new__( new_cls.rule_name = rule_name return dataclasses.dataclass(frozen=True)(new_cls) + @classmethod + def get_rule_cls(cls, game_name: str, rule_name: str) -> "type[Rule[Any]]": + """Returns the world-registered or default rule with the given name""" + custom_rule_classes = cls.custom_rules.get(game_name, {}) + if rule_name not in DEFAULT_RULES and rule_name not in custom_rule_classes: + raise ValueError(f"Rule '{rule_name}' for game '{game_name}' not found") + return custom_rule_classes.get(rule_name) or DEFAULT_RULES[rule_name] + @dataclasses.dataclass() class Rule(Generic[TWorld]): @@ -405,6 +435,13 @@ class Rule(Generic[TWorld]): options: "Iterable[OptionFilter[Any]]" = dataclasses.field(default=(), kw_only=True) """An iterable of OptionFilters to restrict what options are required for this rule to be active""" + game_name: ClassVar[str] + """The name of the game this rule belongs to, default rules belong to 'Archipelago'""" + + def __post_init__(self) -> None: + if not isinstance(self.options, tuple): + self.options = tuple(self.options) + def _passes_options(self, options: "CommonOptions") -> bool: """Tests if the given world options pass the requirements for this rule""" for option_filter in self.options: @@ -438,20 +475,22 @@ def resolve(self, world: "TWorld") -> "Resolved": world.rule_ids[rule_hash] = instance return world.rule_ids[rule_hash] - def to_json(self) -> dict[str, Any]: - """Returns a JSON-serializable definition of this rule""" + def to_dict(self) -> dict[str, Any]: + """Returns a JSON compatible dict representation of this rule""" args = { field.name: getattr(self, field.name, None) for field in dataclasses.fields(self) if field.name != "options" } return { - "rule": self.__class__.__name__, - "options": [o.to_json() for o in self.options], + "rule": self.__class__.__qualname__, + "options": [o.to_dict() for o in self.options], "args": args, } @classmethod - def from_json(cls, data: Mapping[str, Any]) -> Self: - return cls(**data.get("args", {})) + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + """Returns a new instance of this rule from a serialized dict representation""" + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(**data.get("args", {}), options=options) def __and__(self, other: "Rule[Any]") -> "Rule[TWorld]": """Combines two rules into an And rule""" @@ -496,6 +535,7 @@ def __init_subclass__(cls, /, game: str) -> None: elif cls.__module__ != "rule_builder": # TODO: test to make sure this works on frozen raise TypeError("You cannot define custom rules for the base Archipelago world") + cls.game_name = game class Resolved(metaclass=CustomRuleRegister): """A resolved rule for a given world that can be used as an access rule""" @@ -508,9 +548,6 @@ class Resolved(metaclass=CustomRuleRegister): cacheable: bool = dataclasses.field(repr=False, default=True, kw_only=True) """If this rule should be cached in the state""" - rule_name: ClassVar[str] = "Rule" - """The name of this rule for hashing purposes""" - always_true: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" @@ -621,16 +658,18 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return world.simplify_rule(self.Resolved(tuple(children), player=world.player)) @override - def to_json(self) -> dict[str, Any]: - data = super().to_json() + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() del data["args"] - data["children"] = [c.to_json() for c in self.children] + data["children"] = [c.to_dict() for c in self.children] return data @override @classmethod - def from_json(cls, data: Mapping[str, Any]) -> Self: - return cls(*data.get("children", []), options=data.get("options", ())) + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + children = [world_cls.rule_from_dict(c) for c in data.get("children", ())] + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(*children, options=options) @override def __str__(self) -> str: @@ -759,19 +798,20 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return self.Resolved(self.child.resolve(world), player=world.player) @override - def to_json(self) -> dict[str, Any]: - data = super().to_json() + def to_dict(self) -> dict[str, Any]: + data = super().to_dict() del data["args"] - data["child"] = self.child.to_json() + data["child"] = self.child.to_dict() return data @override @classmethod - def from_json(cls, data: Mapping[str, Any]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: child = data.get("child") if child is None: raise ValueError("Child rule cannot be None") - return cls(child, options=data.get("options", ())) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(world_cls.rule_from_dict(child), options=options) @override def __str__(self) -> str: @@ -898,6 +938,14 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + args = {**data.get("args", {})} + item_names = args.pop("item_names", ()) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(*item_names, **args, options=options) + @override def __str__(self) -> str: items = ", ".join(self.item_names) @@ -978,7 +1026,7 @@ def __str__(self) -> str: return f"Has all of ({items})" -@dataclasses.dataclass() +@dataclasses.dataclass(init=False) class HasAny(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least one of the given items""" @@ -997,6 +1045,14 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + args = {**data.get("args", {})} + item_names = args.pop("item_names", ()) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(*item_names, **args, options=options) + @override def __str__(self) -> str: items = ", ".join(self.item_names) @@ -1264,7 +1320,7 @@ def __str__(self) -> str: return f"Has any of ({items})" -@dataclasses.dataclass() +@dataclasses.dataclass(init=False) class HasFromList(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of the given items""" @@ -1289,6 +1345,14 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, self.count, player=world.player) + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + args = {**data.get("args", {})} + item_names = args.pop("item_names", ()) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(*item_names, **args, options=options) + @override def __str__(self) -> str: items = ", ".join(self.item_names) @@ -1369,7 +1433,7 @@ def __str__(self) -> str: return f"Has {self.count} items from ({items})" -@dataclasses.dataclass() +@dataclasses.dataclass(init=False) class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of the given items, ignoring duplicates of the same item""" @@ -1403,7 +1467,7 @@ def _instantiate(self, world: "TWorld") -> "Resolved": def __str__(self) -> str: count = f", count={self.count}" if self.count > 1 else "" options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}(item_name_group={self.item_name_group}{count}{options})" + return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" class Resolved(Rule.Resolved): item_name_group: str diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index a83655651e1c..b70b04d35e9d 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -1,6 +1,6 @@ import unittest from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from typing_extensions import override @@ -469,3 +469,153 @@ def test_has_group_unique(self) -> None: self.assertTrue(resolved_rule.test(self.state)) self.state.remove(items[1]) self.assertFalse(resolved_rule.test(self.state)) + + +class TestSerialization(unittest.TestCase): + maxDiff: int | None = None + + rule: ClassVar[Rule[Any]] = And( + Or( + Has("i1", count=4), + HasFromList("i2", "i3", "i4", count=2), + HasAnyCount({"i5": 2, "i6": 3}), + options=[OptionFilter(ToggleOption, 0)], + ), + Or( + HasAll("i7", "i8"), + HasAllCounts({"i9": 1, "i10": 5}, options=[OptionFilter(ToggleOption, 1, operator="ne")]), + CanReachRegion("r1"), + HasGroup("g1"), + ), + And( + HasAny("i11", "i12"), + CanReachLocation("l1", "r2"), + HasFromListUnique("i13", "i14"), + options=[ + OptionFilter(ToggleOption, ToggleOption.option_false), + OptionFilter(ChoiceOption, ChoiceOption.option_second, "ge"), + ], + ), + CanReachEntrance("e1"), + HasGroupUnique("g2", count=5), + ) + + rule_dict: ClassVar[dict[str, Any]] = { + "rule": "And", + "options": [], + "children": [ + { + "rule": "Or", + "options": [ + { + "option": "test.general.test_rule_builder.ToggleOption", + "value": 0, + "operator": "eq", + }, + ], + "children": [ + { + "rule": "Has", + "options": [], + "args": {"item_name": "i1", "count": 4}, + }, + { + "rule": "HasFromList", + "options": [], + "args": {"item_names": ("i2", "i3", "i4"), "count": 2}, + }, + { + "rule": "HasAnyCount", + "options": [], + "args": {"item_counts": {"i5": 2, "i6": 3}}, + }, + ], + }, + { + "rule": "Or", + "options": [], + "children": [ + { + "rule": "HasAll", + "options": [], + "args": {"item_names": ("i7", "i8")}, + }, + { + "rule": "HasAllCounts", + "options": [ + { + "option": "test.general.test_rule_builder.ToggleOption", + "value": 1, + "operator": "ne", + }, + ], + "args": {"item_counts": {"i9": 1, "i10": 5}}, + }, + { + "rule": "CanReachRegion", + "options": [], + "args": {"region_name": "r1"}, + }, + { + "rule": "HasGroup", + "options": [], + "args": {"item_name_group": "g1", "count": 1}, + }, + ], + }, + { + "rule": "And", + "options": [ + { + "option": "test.general.test_rule_builder.ToggleOption", + "value": 0, + "operator": "eq", + }, + { + "option": "test.general.test_rule_builder.ChoiceOption", + "value": 1, + "operator": "ge", + }, + ], + "children": [ + { + "rule": "HasAny", + "options": [], + "args": {"item_names": ("i11", "i12")}, + }, + { + "rule": "CanReachLocation", + "options": [], + "args": {"location_name": "l1", "parent_region_name": "r2", "skip_indirect_connection": False}, + }, + { + "rule": "HasFromListUnique", + "options": [], + "args": {"item_names": ("i13", "i14"), "count": 1}, + }, + ], + }, + { + "rule": "CanReachEntrance", + "options": [], + "args": {"entrance_name": "e1", "parent_region_name": ""}, + }, + { + "rule": "HasGroupUnique", + "options": [], + "args": {"item_name_group": "g2", "count": 5}, + }, + ], + } + + def test_serialize(self) -> None: + serialized_rule = self.rule.to_dict() + self.assertDictEqual(serialized_rule, self.rule_dict) + + def test_deserialize(self) -> None: + multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=(), seed=0) + world = multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + + deserialized_rule = world.rule_from_dict(self.rule_dict) + self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule)) From 28d4edc938215aca42d21eab43283a4e310eb962 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 8 Jul 2025 11:43:51 -0400 Subject: [PATCH 057/135] add prop to disable caching --- rule_builder.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index c38c0cbff721..1ff3674fffef 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -45,6 +45,8 @@ class RuleWorldMixin(World): Useful when there are multiple versions of a collected item but the logic only uses one. For example: item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}""" + rule_caching_enabled: ClassVar[bool] = True + def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} @@ -68,14 +70,15 @@ def rule_from_dict(cls, data: Mapping[str, Any]) -> "Rule[Self]": def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": """Returns a resolved rule registered with the caching system for this world""" resolved_rule = rule.resolve(self) - for item_name, rule_ids in resolved_rule.item_dependencies().items(): - self.rule_item_dependencies[item_name] |= rule_ids - for region_name, rule_ids in resolved_rule.region_dependencies().items(): - self.rule_region_dependencies[region_name] |= rule_ids - for location_name, rule_ids in resolved_rule.location_dependencies().items(): - self.rule_location_dependencies[location_name] |= rule_ids - for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): - self.rule_entrance_dependencies[entrance_name] |= rule_ids + if self.rule_caching_enabled: + for item_name, rule_ids in resolved_rule.item_dependencies().items(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name, rule_ids in resolved_rule.region_dependencies().items(): + self.rule_region_dependencies[region_name] |= rule_ids + for location_name, rule_ids in resolved_rule.location_dependencies().items(): + self.rule_location_dependencies[location_name] |= rule_ids + for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): + self.rule_entrance_dependencies[entrance_name] |= rule_ids return resolved_rule def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: @@ -85,6 +88,9 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E def register_dependencies(self) -> None: """Register all rules that depend on locations with that location's dependencies""" + if not self.rule_caching_enabled: + return + for location_name, rule_ids in self.rule_location_dependencies.items(): try: location = self.get_location(location_name) @@ -259,7 +265,7 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": @override def collect(self, state: "CollectionState", item: "Item") -> bool: changed = super().collect(state, item) - if changed and getattr(self, "rule_item_dependencies", None): + if changed and self.rule_caching_enabled and getattr(self, "rule_item_dependencies", None): player_results = state.rule_cache[self.player] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] @@ -271,7 +277,7 @@ def collect(self, state: "CollectionState", item: "Item") -> bool: @override def remove(self, state: "CollectionState", item: "Item") -> bool: changed = super().remove(state, item) - if not changed: + if not changed or not self.rule_caching_enabled: return changed player_results = state.rule_cache[self.player] @@ -304,7 +310,7 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: @override def reached_region(self, state: "CollectionState", region: "Region") -> None: super().reached_region(state, region) - if getattr(self, "rule_region_dependencies", None): + if self.rule_caching_enabled and getattr(self, "rule_region_dependencies", None): player_results = state.rule_cache[self.player] for rule_id in self.rule_region_dependencies[region.name]: player_results.pop(rule_id, None) @@ -470,6 +476,8 @@ def resolve(self, world: "TWorld") -> "Resolved": return False_.Resolved(player=world.player) instance = self._instantiate(world) + if not world.rule_caching_enabled: + object.__setattr__(instance, "cacheable", False) rule_hash = hash(instance) if rule_hash not in world.rule_ids: world.rule_ids[rule_hash] = instance From 4a5436e8c1046e282a10db3e7a7e4cfc4f1643fa Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 8 Jul 2025 12:12:16 -0400 Subject: [PATCH 058/135] switch to __call__ and revert access_rule changes --- BaseClasses.py | 34 +------ rule_builder.py | 67 +++++++------- test/general/test_rule_builder.py | 90 +++++++++---------- worlds/generic/Rules.py | 4 +- worlds/sc2/Locations.py | 4 +- worlds/sc2/__init__.py | 2 +- .../stardew_rule/rule_explain.py | 4 +- 7 files changed, 87 insertions(+), 118 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 5666864f6f24..e838021fa534 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1078,9 +1078,7 @@ class EntranceType(IntEnum): class Entrance: - default_access_rule: Final[Callable[[CollectionState], bool]] = staticmethod(lambda state: True) - _access_rule: Callable[[CollectionState], bool] = default_access_rule - resolved_rule: "Rule.Resolved | None" = None + access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False player: int name: str @@ -1097,19 +1095,6 @@ def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, self.randomization_group = randomization_group self.randomization_type = randomization_type - @property - def access_rule(self) -> Callable[[CollectionState], bool]: - return self._access_rule - - @access_rule.setter - def access_rule(self, value: "Callable[[CollectionState], bool] | Rule.Resolved") -> None: - if callable(value): - self._access_rule = value - self.resolved_rule = None - else: - self._access_rule = value.test - self.resolved_rule = value - def can_reach(self, state: CollectionState) -> bool: assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region" if self.parent_region.can_reach(state) and self.access_rule(state): @@ -1393,9 +1378,7 @@ class Location: show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) - default_access_rule: Final[Callable[[CollectionState], bool]] = staticmethod(lambda state: True) - _access_rule: Callable[[CollectionState], bool] = default_access_rule - resolved_rule: "Rule.Resolved | None" =None + access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item: Optional[Item] = None @@ -1405,19 +1388,6 @@ def __init__(self, player: int, name: str = '', address: Optional[int] = None, p self.address = address self.parent_region = parent - @property - def access_rule(self) -> Callable[[CollectionState], bool]: - return self._access_rule - - @access_rule.setter - def access_rule(self, value: "Callable[[CollectionState], bool] | Rule.Resolved") -> None: - if callable(value): - self._access_rule = value - self.resolved_rule = None - else: - self._access_rule = value.test - self.resolved_rule = value - def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool: return (( self.always_allow(state, item) diff --git a/rule_builder.py b/rule_builder.py index 1ff3674fffef..a705daf0fda3 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -46,6 +46,7 @@ class RuleWorldMixin(World): item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}""" rule_caching_enabled: ClassVar[bool] = True + """Enable or disable the rule result caching system""" def __init__(self, multiworld: "MultiWorld", player: int) -> None: super().__init__(multiworld, player) @@ -96,11 +97,11 @@ def register_dependencies(self) -> None: location = self.get_location(location_name) except KeyError: continue - if location.resolved_rule is None: + if not isinstance(location.access_rule, Rule.Resolved): continue - for item_name in location.resolved_rule.item_dependencies(): + for item_name in location.access_rule.item_dependencies(): self.rule_item_dependencies[item_name] |= rule_ids - for region_name in location.resolved_rule.region_dependencies(): + for region_name in location.access_rule.region_dependencies(): self.rule_region_dependencies[region_name] |= rule_ids for entrance_name, rule_ids in self.rule_entrance_dependencies.items(): @@ -108,11 +109,11 @@ def register_dependencies(self) -> None: entrance = self.get_entrance(entrance_name) except KeyError: continue - if entrance.resolved_rule is None: + if not isinstance(entrance.access_rule, Rule.Resolved): continue - for item_name in entrance.resolved_rule.item_dependencies(): + for item_name in entrance.access_rule.item_dependencies(): self.rule_item_dependencies[item_name] |= rule_ids - for region_name in entrance.resolved_rule.region_dependencies(): + for region_name in entrance.access_rule.region_dependencies(): self.rule_region_dependencies[region_name] |= rule_ids def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: @@ -146,7 +147,7 @@ def create_entrance( def set_completion_rule(self, rule: "Rule[Self]") -> None: """Set the completion rule for this world""" resolved_rule = self.resolve_rule(rule) - self.multiworld.completion_condition[self.player] = resolved_rule.test + self.multiworld.completion_condition[self.player] = resolved_rule self.completion_rule = resolved_rule def simplify_rule(self, rule: "Rule.Resolved") -> "Rule.Resolved": @@ -477,7 +478,9 @@ def resolve(self, world: "TWorld") -> "Resolved": instance = self._instantiate(world) if not world.rule_caching_enabled: + # skip the caching logic entirely object.__setattr__(instance, "cacheable", False) + object.__setattr__(instance, "__call__", instance._evaluate) # pyright: ignore[reportPrivateUsage] rule_hash = hash(instance) if rule_hash not in world.rule_ids: world.rule_ids[rule_hash] = instance @@ -562,25 +565,21 @@ class Resolved(metaclass=CustomRuleRegister): always_false: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" - def _evaluate(self, state: "CollectionState") -> bool: - """Calculate this rule's result with the given state""" - ... - - def evaluate(self, state: "CollectionState") -> bool: - """Evaluate this rule's result with the given state and cache the result if applicable""" - result = self._evaluate(state) - if self.cacheable: - state.rule_cache[self.player][id(self)] = result - return result - - def test(self, state: "CollectionState") -> bool: + def __call__(self, state: "CollectionState") -> bool: """Evaluate this rule's result with the given state, using the cached value if possible""" cached_result = None if self.cacheable: cached_result = state.rule_cache[self.player].get(id(self)) if cached_result is not None: return cached_result - return self.evaluate(state) + result = self._evaluate(state) + if self.cacheable: + state.rule_cache[self.player][id(self)] = result + return result + + def _evaluate(self, state: "CollectionState") -> bool: + """Calculate this rule's result with the given state""" + ... def item_dependencies(self) -> dict[str, set[int]]: """Returns a mapping of item name to set of object ids, used for cache invalidation""" @@ -739,7 +738,7 @@ class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: - if not rule.test(state): + if not rule(state): return False return True @@ -770,7 +769,7 @@ class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: for rule in self.children: - if rule.test(state): + if rule(state): return True return False @@ -905,7 +904,7 @@ def item_dependencies(self) -> dict[str, set[int]]: @override def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - verb = "Missing " if state and not self.test(state) else "Has " + verb = "Missing " if state and not self(state) else "Has " messages: list[JSONMessagePart] = [{"type": "text", "text": verb}] if self.count > 1: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) @@ -917,7 +916,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) - prefix = "Has" if self.test(state) else "Missing" + prefix = "Has" if self(state) else "Missing" count = f"{self.count}x " if self.count > 1 else "" return f"{prefix} {count}{self.item_name}" @@ -1022,7 +1021,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: return str(self) found = [item for item in self.item_names if state.has(item, self.player)] missing = [item for item in self.item_names if item not in found] - prefix = "Has all" if self.test(state) else "Missing some" + prefix = "Has all" if self(state) else "Missing some" found_str = f"Found: {', '.join(found)}" if found else "" missing_str = f"Missing: {', '.join(missing)}" if missing else "" infix = "; " if found and missing else "" @@ -1129,7 +1128,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: return str(self) found = [item for item in self.item_names if state.has(item, self.player)] missing = [item for item in self.item_names if item not in found] - prefix = "Has some" if self.test(state) else "Missing all" + prefix = "Has some" if self(state) else "Missing all" found_str = f"Found: {', '.join(found)}" if found else "" missing_str = f"Missing: {', '.join(missing)}" if missing else "" infix = "; " if found and missing else "" @@ -1230,7 +1229,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: return str(self) found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] missing = [(item, count) for item, count in self.item_counts if (item, count) not in found] - prefix = "Has all" if self.test(state) else "Missing some" + prefix = "Has all" if self(state) else "Missing some" found_str = f"Found: {', '.join([f'{item} x{count}' for item, count in found])}" if found else "" missing_str = f"Missing: {', '.join([f'{item} x{count}' for item, count in missing])}" if missing else "" infix = "; " if found and missing else "" @@ -1316,7 +1315,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: return str(self) found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] missing = [(item, count) for item, count in self.item_counts if (item, count) not in found] - prefix = "Has some" if self.test(state) else "Missing all" + prefix = "Has some" if self(state) else "Missing all" found_str = f"Found: {', '.join([f'{item} x{count}' for item, count in found])}" if found else "" missing_str = f"Missing: {', '.join([f'{item} x{count}' for item, count in missing])}" if missing else "" infix = "; " if found and missing else "" @@ -1602,7 +1601,7 @@ def location_dependencies(self) -> dict[str, set[int]]: def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": if state is None: verb = "Can reach" - elif self.test(state): + elif self(state): verb = "Reached" else: verb = "Cannot reach" @@ -1615,7 +1614,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) - prefix = "Reached" if self.test(state) else "Cannot reach" + prefix = "Reached" if self(state) else "Cannot reach" return f"{prefix} location {self.location_name}" @override @@ -1652,7 +1651,7 @@ def region_dependencies(self) -> dict[str, set[int]]: def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": if state is None: verb = "Can reach" - elif self.test(state): + elif self(state): verb = "Reached" else: verb = "Cannot reach" @@ -1665,7 +1664,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) - prefix = "Reached" if self.test(state) else "Cannot reach" + prefix = "Reached" if self(state) else "Cannot reach" return f"{prefix} region {self.region_name}" @override @@ -1718,7 +1717,7 @@ def entrance_dependencies(self) -> dict[str, set[int]]: def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": if state is None: verb = "Can reach" - elif self.test(state): + elif self(state): verb = "Reached" else: verb = "Cannot reach" @@ -1731,7 +1730,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) - prefix = "Reached" if self.test(state) else "Cannot reach" + prefix = "Reached" if self(state) else "Cannot reach" return f"{prefix} entrance {self.entrance_name}" @override diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index b70b04d35e9d..19418acfc19b 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -72,7 +72,7 @@ class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMu item_name_groups: ClassVar[dict[str, set[str]]] = {"Group 1": {"Item 1", "Item 2", "Item 3"}} hidden: ClassVar[bool] = True options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions - options: RuleBuilderOptions # type: ignore # pyright: ignore[reportIncompatibleVariableOverride] + options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] origin_region_name: str = "Region 1" @override @@ -268,42 +268,42 @@ def setUp(self) -> None: def test_item_cache_busting(self) -> None: entrance = self.world.get_entrance("Region 1 -> Region 2") self.assertFalse(entrance.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(entrance.resolved_rule)]) + self.assertFalse(self.state.rule_cache[1][id(entrance.access_rule)]) self.state.collect(self.world.create_item("Item 1")) # clears cache, item directly needed - self.assertNotIn(id(entrance.resolved_rule), self.state.rule_cache[1]) + self.assertNotIn(id(entrance.access_rule), self.state.rule_cache[1]) self.assertTrue(entrance.can_reach(self.state)) def test_region_cache_busting(self) -> None: location = self.world.get_location("Location 2") self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) + self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for region 2 access # cache gets cleared during the can_reach self.assertTrue(location.can_reach(self.state)) - self.assertTrue(self.state.rule_cache[1][id(location.resolved_rule)]) + self.assertTrue(self.state.rule_cache[1][id(location.access_rule)]) def test_location_cache_busting(self) -> None: location = self.world.get_location("Location 5") self.state.collect(self.world.create_item("Item 1")) # access to region 2 self.state.collect(self.world.create_item("Item 3")) # access to region 3 self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) + self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 2")) # clears cache, item only needed for location 2 access - self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) + self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) self.assertTrue(location.can_reach(self.state)) def test_entrance_cache_busting(self) -> None: location = self.world.get_location("Location 6") self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.resolved_rule)]) + self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for entrance access - self.assertNotIn(id(location.resolved_rule), self.state.rule_cache[1]) + self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) self.assertTrue(location.can_reach(self.state)) @@ -324,64 +324,64 @@ def setUp(self) -> None: def test_true(self) -> None: rule = True_() resolved_rule = self.world.resolve_rule(rule) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) def test_false(self) -> None: rule = False_() resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has(self) -> None: rule = Has("Item 1") resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item = self.world.create_item("Item 1") self.state.collect(item) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(item) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_all(self) -> None: rule = HasAll("Item 1", "Item 2") resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item1 = self.world.create_item("Item 1") self.state.collect(item1) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item2 = self.world.create_item("Item 2") self.state.collect(item2) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(item1) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_any(self) -> None: item_names = ("Item 1", "Item 2") rule = HasAny(*item_names) resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) for item_name in item_names: item = self.world.create_item(item_name) self.state.collect(item) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(item) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_all_counts(self) -> None: rule = HasAllCounts({"Item 1": 1, "Item 2": 2}) resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item1 = self.world.create_item("Item 1") self.state.collect(item1) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item2 = self.world.create_item("Item 2") self.state.collect(item2) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item2 = self.world.create_item("Item 2") self.state.collect(item2) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(item2) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_any_count(self) -> None: item_counts = {"Item 1": 1, "Item 2": 2} @@ -391,17 +391,17 @@ def test_has_any_count(self) -> None: for item_name, count in item_counts.items(): item = self.world.create_item(item_name) for _ in range(count): - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) self.state.collect(item) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(item) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_from_list(self) -> None: item_names = ("Item 1", "Item 2", "Item 3") rule = HasFromList(*item_names, count=2) resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) items: list[Item] = [] for i, item_name in enumerate(item_names): @@ -409,19 +409,19 @@ def test_has_from_list(self) -> None: self.state.collect(item) items.append(item) if i == 0: - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) else: - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) for i in range(2): self.state.remove(items[i]) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_from_list_unique(self) -> None: item_names = ("Item 1", "Item 1", "Item 2") rule = HasFromListUnique(*item_names, count=2) resolved_rule = self.world.resolve_rule(rule) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) items: list[Item] = [] for i, item_name in enumerate(item_names): @@ -429,14 +429,14 @@ def test_has_from_list_unique(self) -> None: self.state.collect(item) items.append(item) if i < 2: - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) else: - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(items[0]) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(items[1]) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_group(self) -> None: rule = HasGroup("Group 1", count=2) @@ -444,14 +444,14 @@ def test_has_group(self) -> None: items: list[Item] = [] for item_name in ("Item 1", "Item 2"): - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item = self.world.create_item(item_name) self.state.collect(item) items.append(item) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(items[0]) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) def test_has_group_unique(self) -> None: rule = HasGroupUnique("Group 1", count=2) @@ -459,16 +459,16 @@ def test_has_group_unique(self) -> None: items: list[Item] = [] for item_name in ("Item 1", "Item 1", "Item 2"): - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) item = self.world.create_item(item_name) self.state.collect(item) items.append(item) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(items[0]) - self.assertTrue(resolved_rule.test(self.state)) + self.assertTrue(resolved_rule(self.state)) self.state.remove(items[1]) - self.assertFalse(resolved_rule.test(self.state)) + self.assertFalse(resolved_rule(self.state)) class TestSerialization(unittest.TestCase): diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index a06f94fe66c4..31d725bff722 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -2,7 +2,7 @@ import logging import typing -from BaseClasses import Entrance, Location, LocationProgressType, MultiWorld, Region +from BaseClasses import LocationProgressType, MultiWorld, Location, Region, Entrance if typing.TYPE_CHECKING: import BaseClasses @@ -103,7 +103,7 @@ def set_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule, combine="and"): old_rule = spot.access_rule # empty rule, replace instead of add - if old_rule is Location.default_access_rule or old_rule is Entrance.default_access_rule: + if old_rule is Location.access_rule or old_rule is Entrance.access_rule: spot.access_rule = rule if combine == "and" else old_rule else: if combine == "and": diff --git a/worlds/sc2/Locations.py b/worlds/sc2/Locations.py index 78a63bb4b95a..42b1dd4d4eb0 100644 --- a/worlds/sc2/Locations.py +++ b/worlds/sc2/Locations.py @@ -32,7 +32,7 @@ class LocationData(NamedTuple): name: str code: Optional[int] type: LocationType - rule: Optional[Callable[[Any], bool]] = Location.default_access_rule + rule: Optional[Callable[[Any], bool]] = Location.access_rule def get_location_types(world: World, inclusion_type: LocationInclusion) -> Set[LocationType]: @@ -1623,7 +1623,7 @@ def get_locations(world: Optional[World]) -> Tuple[LocationData, ...]: for i, location_data in enumerate(location_table): # Removing all item-based logic on No Logic if logic_level == RequiredTactics.option_no_logic: - location_data = location_data._replace(rule=Location.default_access_rule) + location_data = location_data._replace(rule=Location.access_rule) location_table[i] = location_data # Generating Beat event locations if location_data.name.endswith((": Victory", ": Defeat")): diff --git a/worlds/sc2/__init__.py b/worlds/sc2/__init__.py index 598d06776c62..f11059a54ef5 100644 --- a/worlds/sc2/__init__.py +++ b/worlds/sc2/__init__.py @@ -222,7 +222,7 @@ def assign_starter_items(world: World, excluded_items: Set[str], locked_location if starter_unit == StarterUnit.option_off: starter_mission_locations = [location.name for location in location_cache if location.parent_region.name == first_mission - and location.access_rule == Location.default_access_rule] + and location.access_rule == Location.access_rule] if not starter_mission_locations: # Force early unit if first mission is impossible without one starter_unit = StarterUnit.option_any_starter_unit diff --git a/worlds/stardew_valley/stardew_rule/rule_explain.py b/worlds/stardew_valley/stardew_rule/rule_explain.py index d8599ab4e716..2e2b9c959d7f 100644 --- a/worlds/stardew_valley/stardew_rule/rule_explain.py +++ b/worlds/stardew_valley/stardew_rule/rule_explain.py @@ -127,7 +127,7 @@ def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[T access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] else: access_rules = [spot.access_rule, Reach(spot.parent_region.name, "Region", rule.player)] - elif spot.access_rule == Location.default_access_rule: + elif spot.access_rule == Location.access_rule: # Sometime locations just don't have an access rule and all the relevant logic is in the parent region. access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] @@ -140,7 +140,7 @@ def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[T access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] else: access_rules = [spot.access_rule, Reach(spot.parent_region.name, "Region", rule.player)] - elif spot.access_rule == Entrance.default_access_rule: + elif spot.access_rule == Entrance.access_rule: # Sometime entrances just don't have an access rule and all the relevant logic is in the parent region. access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] From 0a8099af2463a369a00a868c269975e830f98e0c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 8 Jul 2025 12:41:06 -0400 Subject: [PATCH 059/135] update docs and make edge cases match --- docs/rule builder.md | 13 ++++++++++++- rule_builder.py | 24 ++++++++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 87952a05bb84..f6bdac8d969b 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -109,6 +109,17 @@ rule = Or( ) ``` +### Disabling caching + +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 can disable the caching system entirely by setting the `rule_caching_enabled` class property to `False` on your world: + +```python +class MyWorld(RuleWorldMixin, World): + rule_caching_enabled = False +``` + +You'll have to benchmark your own world to see if it should be disabled or not. + ## 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 to provide your world as a type argument to add correct type checking to the `_instantiate` method. @@ -148,7 +159,7 @@ class MyRule(Rule["MyWorld"], game="My Game"): return {self.item_name: {id(self)}} ``` -The default `Has`, `HasAll`, and `HasAny` rules define this function already. +All of the default `Has*` rules define this function already. ### Region dependencies diff --git a/rule_builder.py b/rule_builder.py index a705daf0fda3..5f90cbff2736 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -940,6 +940,7 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: + # match state.has_all return True_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) @@ -1047,7 +1048,8 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: - return True_().resolve(world) + # match state.has_any + return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @@ -1150,6 +1152,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 0: + # match state.has_all_counts return True_().resolve(world) if len(self.item_counts) == 1: item = next(iter(self.item_counts)) @@ -1248,7 +1251,8 @@ class HasAnyCount(HasAllCounts[TWorld], game="Archipelago"): @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 0: - return True_().resolve(world) + # match state.has_any_count + return False_().resolve(world) if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) @@ -1344,12 +1348,11 @@ def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFi @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": - if len(self.item_names) < self.count: - return False_().resolve(world) if len(self.item_names) == 0: - return True_().resolve(world) + # match state.has_from_list + return False_().resolve(world) if len(self.item_names) == 1: - return Has(self.item_names[0]).resolve(world) + return Has(self.item_names[0], self.count).resolve(world) return self.Resolved(self.item_names, self.count, player=world.player) @override @@ -1449,6 +1452,15 @@ def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFi self.item_names: tuple[str, ...] = tuple(sorted(set(item_names))) self.count: int = count + @override + def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + if len(self.item_names) == 0 or len(self.item_names) < self.count: + # match state.has_from_list_unique + return False_().resolve(world) + if len(self.item_names) == 1: + return Has(self.item_names[0]).resolve(world) + return self.Resolved(self.item_names, self.count, player=world.player) + class Resolved(HasFromList.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: From e8c5569ab4af3c7b0dea11d990ab61c461debb40 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 8 Jul 2025 14:15:22 -0400 Subject: [PATCH 060/135] ruff has lured me into a false sense of security --- BaseClasses.py | 2 +- worlds/AutoWorld.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index e838021fa534..8872a98417f4 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -9,7 +9,7 @@ from collections import Counter, deque from collections.abc import Collection, MutableSequence from enum import IntEnum, IntFlag -from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Final, Iterable, Iterator, List, Literal, Mapping, NamedTuple, +from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) import dataclasses diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index c540651dd2f3..22593598f4e6 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -86,7 +86,7 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut new_class.__file__ = sys.modules[new_class.__module__].__file__ if "game" in dct: if dct["game"] in AutoWorldRegister.world_types: - raise RuntimeError(f"""Game {dct["game"]} already registered in + raise RuntimeError(f"""Game {dct["game"]} already registered in {AutoWorldRegister.world_types[dct["game"]].__file__} when attempting to register from {new_class.__file__}.""") AutoWorldRegister.world_types[dct["game"]] = new_class @@ -312,7 +312,7 @@ class World(metaclass=AutoWorldRegister): explicit_indirect_conditions: bool = True """If True, the world implementation is supposed to use MultiWorld.register_indirect_condition() correctly. - If False, everything is rechecked at every step, which is slower computationally, + If False, everything is rechecked at every step, which is slower computationally, but may be desirable in complex/dynamic worlds.""" multiworld: "MultiWorld" From 5ce1e70e11afaa8db8780fde77060246712594fd Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 8 Jul 2025 14:19:38 -0400 Subject: [PATCH 061/135] also unused --- BaseClasses.py | 1 - 1 file changed, 1 deletion(-) diff --git a/BaseClasses.py b/BaseClasses.py index 8872a98417f4..07750950a10f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -21,7 +21,6 @@ if TYPE_CHECKING: from entrance_rando import ERPlacementState - from rule_builder import Rule from worlds import AutoWorld From a62156f01ef6cd0558b72ec34b3eed00099c3e7e Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 8 Jul 2025 16:39:25 -0400 Subject: [PATCH 062/135] update rule builder --- worlds/astalon/client.py | 9 +- worlds/astalon/items.py | 10 +- worlds/astalon/locations.py | 730 +++++++-------------------- worlds/astalon/logic/custom_rules.py | 18 +- worlds/astalon/world.py | 5 +- 5 files changed, 201 insertions(+), 571 deletions(-) diff --git a/worlds/astalon/client.py b/worlds/astalon/client.py index 62f7906269fd..5aa2caeebbb2 100644 --- a/worlds/astalon/client.py +++ b/worlds/astalon/client.py @@ -1,5 +1,6 @@ import asyncio import urllib.parse +from collections.abc import Callable from typing import TYPE_CHECKING from CommonClient import CommonContext, get_base_parser, gui_enabled, logger, server_loop @@ -47,8 +48,8 @@ class TrackerGameContext(CommonContext, TrackerGameContextMixin): class AstalonCommandProcessor(ClientCommandProcessor): # type: ignore ctx: "AstalonClientContext" - def _print_rule(self, rule: "Rule.Resolved | None", state: "CollectionState") -> None: - if rule: + def _print_rule(self, rule: "Callable[[CollectionState], bool]", state: "CollectionState") -> None: + if isinstance(rule, Rule.Resolved): if self.ctx.ui: messages: list[JSONMessagePart] = [{"type": "text", "text": " "}] messages.extend(rule.explain_json(state)) @@ -126,7 +127,7 @@ def _cmd_route(self, location_or_region: str = "") -> None: self.ctx.ui.print_json([{"type": "entrance_name", "text": p.name, "player": self.ctx.player_id}]) else: logger.info(p.name) - self._print_rule(p.resolved_rule, state) + self._print_rule(p.access_rule, state) if goal_location: if self.ctx.ui: @@ -142,7 +143,7 @@ def _cmd_route(self, location_or_region: str = "") -> None: ) else: logger.info(f"-> {goal_location.name}") - self._print_rule(goal_location.resolved_rule, state) + self._print_rule(goal_location.access_rule, state) class AstalonClientContext(TrackerGameContext): diff --git a/worlds/astalon/items.py b/worlds/astalon/items.py index 02f0dfa92ad1..6c8fe6214db7 100644 --- a/worlds/astalon/items.py +++ b/worlds/astalon/items.py @@ -503,7 +503,7 @@ class Events(str, Enum): class AstalonItem(Item): - game = GAME_NAME + game: str = GAME_NAME @dataclass(frozen=True) @@ -669,17 +669,13 @@ class ItemData: ItemData(Switch.GT_UPPER_PATH_ACCESS, ItemClassification.progression, 1, ItemGroup.SWITCH), ItemData( Switch.GT_CROSSES, - lambda world: ( - ItemClassification.filler if world.options.open_early_doors else ItemClassification.progression - ), + lambda world: (ItemClassification.filler if world.options.open_early_doors else ItemClassification.progression), 1, ItemGroup.SWITCH, ), ItemData( Switch.GT_GH_SHORTCUT, - lambda world: ( - ItemClassification.filler if world.options.open_early_doors else ItemClassification.progression - ), + lambda world: (ItemClassification.filler if world.options.open_early_doors else ItemClassification.progression), 1, ItemGroup.SWITCH, ), diff --git a/worlds/astalon/locations.py b/worlds/astalon/locations.py index bd42fc8f0759..ae19d0c44b37 100644 --- a/worlds/astalon/locations.py +++ b/worlds/astalon/locations.py @@ -510,7 +510,7 @@ class LocationName(str, Enum): class AstalonLocation(Location): - game = GAME_NAME + game: str = GAME_NAME @dataclass(frozen=True) @@ -562,43 +562,25 @@ class LocationData: LocationData(LocationName.MECH_ATTACK_STAR, RegionName.MECH_CHAINS, LocationGroup.ATTACK, Area.MECH), LocationData(LocationName.MECH_HP_1_SWITCH, RegionName.MECH_RIGHT, LocationGroup.HEALTH, Area.MECH), LocationData(LocationName.MECH_HP_1_STAR, RegionName.MECH_BRAM_TUNNEL, LocationGroup.HEALTH, Area.MECH), - LocationData( - LocationName.MECH_HP_3_CLAW, RegionName.MECH_BOTTOM_CAMPFIRE, LocationGroup.HEALTH, Area.MECH - ), + LocationData(LocationName.MECH_HP_3_CLAW, RegionName.MECH_BOTTOM_CAMPFIRE, LocationGroup.HEALTH, Area.MECH), LocationData( LocationName.MECH_WHITE_KEY_LINUS, RegionName.MECH_SWORD_CONNECTION, LocationGroup.KEY_WHITE, Area.MECH, ), - LocationData( - LocationName.MECH_WHITE_KEY_BK, RegionName.MECH_AFTER_BK, LocationGroup.KEY_WHITE, Area.MECH - ), - LocationData( - LocationName.MECH_WHITE_KEY_ARENA, RegionName.MECH_RIGHT, LocationGroup.KEY_WHITE, Area.MECH - ), + LocationData(LocationName.MECH_WHITE_KEY_BK, RegionName.MECH_AFTER_BK, LocationGroup.KEY_WHITE, Area.MECH), + LocationData(LocationName.MECH_WHITE_KEY_ARENA, RegionName.MECH_RIGHT, LocationGroup.KEY_WHITE, Area.MECH), LocationData(LocationName.MECH_WHITE_KEY_TOP, RegionName.MECH_TOP, LocationGroup.KEY_WHITE, Area.MECH), LocationData(LocationName.MECH_BLUE_KEY_VOID, RegionName.GT_VOID, LocationGroup.KEY_BLUE, Area.MECH), LocationData(LocationName.MECH_BLUE_KEY_SNAKE, RegionName.MECH_SNAKE, LocationGroup.KEY_BLUE, Area.MECH), - LocationData( - LocationName.MECH_BLUE_KEY_LINUS, RegionName.MECH_LOWER_ARIAS, LocationGroup.KEY_BLUE, Area.MECH - ), - LocationData( - LocationName.MECH_BLUE_KEY_SACRIFICE, RegionName.MECH_SACRIFICE, LocationGroup.KEY_BLUE, Area.MECH - ), + LocationData(LocationName.MECH_BLUE_KEY_LINUS, RegionName.MECH_LOWER_ARIAS, LocationGroup.KEY_BLUE, Area.MECH), + LocationData(LocationName.MECH_BLUE_KEY_SACRIFICE, RegionName.MECH_SACRIFICE, LocationGroup.KEY_BLUE, Area.MECH), LocationData(LocationName.MECH_BLUE_KEY_RED, RegionName.MECH_START, LocationGroup.KEY_BLUE, Area.MECH), - LocationData( - LocationName.MECH_BLUE_KEY_ARIAS, RegionName.MECH_ARIAS_EYEBALL, LocationGroup.KEY_BLUE, Area.MECH - ), - LocationData( - LocationName.MECH_BLUE_KEY_BLOCKS, RegionName.MECH_CHAINS, LocationGroup.KEY_BLUE, Area.MECH - ), - LocationData( - LocationName.MECH_BLUE_KEY_TOP, RegionName.MECH_SPLIT_PATH, LocationGroup.KEY_BLUE, Area.MECH - ), - LocationData( - LocationName.MECH_BLUE_KEY_OLD_MAN, RegionName.MECH_RIGHT, LocationGroup.KEY_BLUE, Area.MECH - ), + LocationData(LocationName.MECH_BLUE_KEY_ARIAS, RegionName.MECH_ARIAS_EYEBALL, LocationGroup.KEY_BLUE, Area.MECH), + LocationData(LocationName.MECH_BLUE_KEY_BLOCKS, RegionName.MECH_CHAINS, LocationGroup.KEY_BLUE, Area.MECH), + LocationData(LocationName.MECH_BLUE_KEY_TOP, RegionName.MECH_SPLIT_PATH, LocationGroup.KEY_BLUE, Area.MECH), + LocationData(LocationName.MECH_BLUE_KEY_OLD_MAN, RegionName.MECH_RIGHT, LocationGroup.KEY_BLUE, Area.MECH), LocationData(LocationName.MECH_BLUE_KEY_SAVE, RegionName.MECH_TOP, LocationGroup.KEY_BLUE, Area.MECH), LocationData(LocationName.MECH_BLUE_KEY_POT, RegionName.MECH_POTS, LocationGroup.KEY_BLUE, Area.MECH), LocationData(LocationName.MECH_RED_KEY, RegionName.MECH_LOWER_VOID, LocationGroup.KEY_RED, Area.MECH), @@ -609,29 +591,15 @@ class LocationData: LocationData(LocationName.HOTP_MAIDEN_RING, RegionName.HOTP_MAIDEN, LocationGroup.ITEM, Area.HOTP), LocationData(LocationName.HOTP_HP_1_CLAW, RegionName.HOTP_CLAW_LEFT, LocationGroup.HEALTH, Area.HOTP), LocationData(LocationName.HOTP_HP_2_LADDER, RegionName.HOTP_ELEVATOR, LocationGroup.HEALTH, Area.HOTP), - LocationData( - LocationName.HOTP_HP_2_GAUNTLET, RegionName.HOTP_TP_FALL_TOP, LocationGroup.HEALTH, Area.HOTP - ), - LocationData( - LocationName.HOTP_HP_5_OLD_MAN, RegionName.HOTP_ABOVE_OLD_MAN, LocationGroup.HEALTH, Area.HOTP - ), + LocationData(LocationName.HOTP_HP_2_GAUNTLET, RegionName.HOTP_TP_FALL_TOP, LocationGroup.HEALTH, Area.HOTP), + LocationData(LocationName.HOTP_HP_5_OLD_MAN, RegionName.HOTP_ABOVE_OLD_MAN, LocationGroup.HEALTH, Area.HOTP), LocationData(LocationName.HOTP_HP_5_MAZE, RegionName.HOTP_LOWER_VOID, LocationGroup.HEALTH, Area.HOTP), LocationData(LocationName.HOTP_HP_5_START, RegionName.HOTP_START, LocationGroup.HEALTH, Area.HOTP), - LocationData( - LocationName.HOTP_WHITE_KEY_LEFT, RegionName.HOTP_START_LEFT, LocationGroup.KEY_WHITE, Area.HOTP - ), - LocationData( - LocationName.HOTP_WHITE_KEY_GHOST, RegionName.HOTP_LOWER, LocationGroup.KEY_WHITE, Area.HOTP - ), - LocationData( - LocationName.HOTP_WHITE_KEY_OLD_MAN, RegionName.HOTP_ELEVATOR, LocationGroup.KEY_WHITE, Area.HOTP - ), - LocationData( - LocationName.HOTP_WHITE_KEY_BOSS, RegionName.HOTP_UPPER_ARIAS, LocationGroup.KEY_WHITE, Area.HOTP - ), - LocationData( - LocationName.HOTP_BLUE_KEY_STATUE, RegionName.HOTP_EPIMETHEUS, LocationGroup.KEY_BLUE, Area.HOTP - ), + LocationData(LocationName.HOTP_WHITE_KEY_LEFT, RegionName.HOTP_START_LEFT, LocationGroup.KEY_WHITE, Area.HOTP), + LocationData(LocationName.HOTP_WHITE_KEY_GHOST, RegionName.HOTP_LOWER, LocationGroup.KEY_WHITE, Area.HOTP), + LocationData(LocationName.HOTP_WHITE_KEY_OLD_MAN, RegionName.HOTP_ELEVATOR, LocationGroup.KEY_WHITE, Area.HOTP), + LocationData(LocationName.HOTP_WHITE_KEY_BOSS, RegionName.HOTP_UPPER_ARIAS, LocationGroup.KEY_WHITE, Area.HOTP), + LocationData(LocationName.HOTP_BLUE_KEY_STATUE, RegionName.HOTP_EPIMETHEUS, LocationGroup.KEY_BLUE, Area.HOTP), LocationData(LocationName.HOTP_BLUE_KEY_GOLD, RegionName.HOTP_LOWER, LocationGroup.KEY_BLUE, Area.HOTP), LocationData( LocationName.HOTP_BLUE_KEY_AMULET, @@ -639,18 +607,14 @@ class LocationData: LocationGroup.KEY_BLUE, Area.HOTP, ), - LocationData( - LocationName.HOTP_BLUE_KEY_LADDER, RegionName.HOTP_ELEVATOR, LocationGroup.KEY_BLUE, Area.HOTP - ), + LocationData(LocationName.HOTP_BLUE_KEY_LADDER, RegionName.HOTP_ELEVATOR, LocationGroup.KEY_BLUE, Area.HOTP), LocationData( LocationName.HOTP_BLUE_KEY_TELEPORTS, RegionName.HOTP_SPIKE_TP_SECRET, LocationGroup.KEY_BLUE, Area.HOTP, ), - LocationData( - LocationName.HOTP_BLUE_KEY_MAZE, RegionName.HOTP_TP_FALL_TOP, LocationGroup.KEY_BLUE, Area.HOTP - ), + LocationData(LocationName.HOTP_BLUE_KEY_MAZE, RegionName.HOTP_TP_FALL_TOP, LocationGroup.KEY_BLUE, Area.HOTP), LocationData(LocationName.HOTP_RED_KEY, RegionName.HOTP_RED_KEY, LocationGroup.KEY_RED, Area.HOTP), LocationData(LocationName.ROA_ICARUS, RegionName.ROA_ICARUS, LocationGroup.ITEM, Area.ROA), LocationData( @@ -665,30 +629,14 @@ class LocationData: LocationData(LocationName.ROA_HP_2_RIGHT, RegionName.ROA_RIGHT_BRANCH, LocationGroup.HEALTH, Area.ROA), LocationData(LocationName.ROA_HP_5_SOLARIA, RegionName.APEX, LocationGroup.HEALTH, Area.ROA), LocationData(LocationName.ROA_WHITE_KEY_SAVE, RegionName.ROA_WORMS, LocationGroup.KEY_WHITE, Area.ROA), - LocationData( - LocationName.ROA_WHITE_KEY_REAPERS, RegionName.ROA_LEFT_ASCENT, LocationGroup.KEY_WHITE, Area.ROA - ), - LocationData( - LocationName.ROA_WHITE_KEY_TORCHES, RegionName.ROA_MIDDLE, LocationGroup.KEY_WHITE, Area.ROA - ), - LocationData( - LocationName.ROA_WHITE_KEY_PORTAL, RegionName.ROA_UPPER_VOID, LocationGroup.KEY_WHITE, Area.ROA - ), - LocationData( - LocationName.ROA_BLUE_KEY_FACE, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.KEY_BLUE, Area.ROA - ), - LocationData( - LocationName.ROA_BLUE_KEY_FLAMES, RegionName.ROA_ARIAS_BABY_GORGON, LocationGroup.KEY_BLUE, Area.ROA - ), - LocationData( - LocationName.ROA_BLUE_KEY_BABY, RegionName.ROA_LEFT_BABY_GORGON, LocationGroup.KEY_BLUE, Area.ROA - ), - LocationData( - LocationName.ROA_BLUE_KEY_TOP, RegionName.ROA_BOSS_CONNECTION, LocationGroup.KEY_BLUE, Area.ROA - ), - LocationData( - LocationName.ROA_BLUE_KEY_POT, RegionName.ROA_TRIPLE_REAPER, LocationGroup.KEY_BLUE, Area.ROA - ), + LocationData(LocationName.ROA_WHITE_KEY_REAPERS, RegionName.ROA_LEFT_ASCENT, LocationGroup.KEY_WHITE, Area.ROA), + LocationData(LocationName.ROA_WHITE_KEY_TORCHES, RegionName.ROA_MIDDLE, LocationGroup.KEY_WHITE, Area.ROA), + LocationData(LocationName.ROA_WHITE_KEY_PORTAL, RegionName.ROA_UPPER_VOID, LocationGroup.KEY_WHITE, Area.ROA), + LocationData(LocationName.ROA_BLUE_KEY_FACE, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.KEY_BLUE, Area.ROA), + LocationData(LocationName.ROA_BLUE_KEY_FLAMES, RegionName.ROA_ARIAS_BABY_GORGON, LocationGroup.KEY_BLUE, Area.ROA), + LocationData(LocationName.ROA_BLUE_KEY_BABY, RegionName.ROA_LEFT_BABY_GORGON, LocationGroup.KEY_BLUE, Area.ROA), + LocationData(LocationName.ROA_BLUE_KEY_TOP, RegionName.ROA_BOSS_CONNECTION, LocationGroup.KEY_BLUE, Area.ROA), + LocationData(LocationName.ROA_BLUE_KEY_POT, RegionName.ROA_TRIPLE_REAPER, LocationGroup.KEY_BLUE, Area.ROA), LocationData(LocationName.ROA_RED_KEY, RegionName.ROA_RED_KEY, LocationGroup.KEY_RED, Area.ROA), LocationData(LocationName.DARK_HP_4, RegionName.DARK_END, LocationGroup.HEALTH, Area.DARK), LocationData(LocationName.DARK_WHITE_KEY, RegionName.DARK_END, LocationGroup.KEY_WHITE, Area.DARK), @@ -697,42 +645,20 @@ class LocationData: LocationData(LocationName.APEX_HP_5_HEART, RegionName.APEX_HEART, LocationGroup.HEALTH, Area.APEX), LocationData(LocationName.APEX_BLUE_KEY, RegionName.APEX, LocationGroup.KEY_BLUE, Area.APEX), LocationData(LocationName.CATA_BOW, RegionName.CATA_BOW, LocationGroup.ITEM, Area.CATA), - LocationData( - LocationName.CAVES_ATTACK_RED, RegionName.CAVES_ITEM_CHAIN, LocationGroup.ATTACK, Area.CAVES - ), - LocationData( - LocationName.CAVES_ATTACK_BLUE, RegionName.CAVES_ITEM_CHAIN, LocationGroup.ATTACK, Area.CAVES - ), - LocationData( - LocationName.CAVES_ATTACK_GREEN, RegionName.CAVES_ITEM_CHAIN, LocationGroup.ATTACK, Area.CAVES - ), - LocationData( - LocationName.CATA_ATTACK_ROOT, RegionName.CATA_CLIMBABLE_ROOT, LocationGroup.ATTACK, Area.CATA - ), - LocationData( - LocationName.CATA_ATTACK_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.ATTACK, Area.CATA - ), + LocationData(LocationName.CAVES_ATTACK_RED, RegionName.CAVES_ITEM_CHAIN, LocationGroup.ATTACK, Area.CAVES), + LocationData(LocationName.CAVES_ATTACK_BLUE, RegionName.CAVES_ITEM_CHAIN, LocationGroup.ATTACK, Area.CAVES), + LocationData(LocationName.CAVES_ATTACK_GREEN, RegionName.CAVES_ITEM_CHAIN, LocationGroup.ATTACK, Area.CAVES), + LocationData(LocationName.CATA_ATTACK_ROOT, RegionName.CATA_CLIMBABLE_ROOT, LocationGroup.ATTACK, Area.CATA), + LocationData(LocationName.CATA_ATTACK_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.ATTACK, Area.CATA), LocationData(LocationName.CAVES_HP_1_START, RegionName.CAVES_START, LocationGroup.HEALTH, Area.CAVES), LocationData(LocationName.CAVES_HP_1_CYCLOPS, RegionName.CAVES_ARENA, LocationGroup.HEALTH, Area.CAVES), - LocationData( - LocationName.CATA_HP_1_ABOVE_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.HEALTH, Area.CATA - ), - LocationData( - LocationName.CATA_HP_2_BEFORE_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.HEALTH, Area.CATA - ), - LocationData( - LocationName.CATA_HP_2_AFTER_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.HEALTH, Area.CATA - ), - LocationData( - LocationName.CATA_HP_2_GEMINI_BOTTOM, RegionName.CATA_DOUBLE_DOOR, LocationGroup.HEALTH, Area.CATA - ), + LocationData(LocationName.CATA_HP_1_ABOVE_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.HEALTH, Area.CATA), + LocationData(LocationName.CATA_HP_2_BEFORE_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.HEALTH, Area.CATA), + LocationData(LocationName.CATA_HP_2_AFTER_POISON, RegionName.CATA_POISON_ROOTS, LocationGroup.HEALTH, Area.CATA), + LocationData(LocationName.CATA_HP_2_GEMINI_BOTTOM, RegionName.CATA_DOUBLE_DOOR, LocationGroup.HEALTH, Area.CATA), LocationData(LocationName.CATA_HP_2_GEMINI_TOP, RegionName.CATA_CENTAUR, LocationGroup.HEALTH, Area.CATA), - LocationData( - LocationName.CATA_HP_2_ABOVE_GEMINI, RegionName.CATA_FLAMES, LocationGroup.HEALTH, Area.CATA - ), - LocationData( - LocationName.CAVES_HP_5_CHAIN, RegionName.CAVES_ITEM_CHAIN, LocationGroup.HEALTH, Area.CAVES - ), + LocationData(LocationName.CATA_HP_2_ABOVE_GEMINI, RegionName.CATA_FLAMES, LocationGroup.HEALTH, Area.CATA), + LocationData(LocationName.CAVES_HP_5_CHAIN, RegionName.CAVES_ITEM_CHAIN, LocationGroup.HEALTH, Area.CAVES), LocationData(LocationName.CATA_WHITE_KEY_HEAD, RegionName.CATA_TOP, LocationGroup.KEY_WHITE, Area.CATA), LocationData( LocationName.CATA_WHITE_KEY_DEV_ROOM, @@ -740,15 +666,9 @@ class LocationData: LocationGroup.KEY_WHITE, Area.CATA, ), - LocationData( - LocationName.CATA_WHITE_KEY_PRISON, RegionName.CATA_BOSS, LocationGroup.KEY_WHITE, Area.CATA - ), - LocationData( - LocationName.CATA_BLUE_KEY_SLIMES, RegionName.CATA_BOW_CAMPFIRE, LocationGroup.KEY_BLUE, Area.CATA - ), - LocationData( - LocationName.CATA_BLUE_KEY_EYEBALLS, RegionName.CATA_CENTAUR, LocationGroup.KEY_BLUE, Area.CATA - ), + LocationData(LocationName.CATA_WHITE_KEY_PRISON, RegionName.CATA_BOSS, LocationGroup.KEY_WHITE, Area.CATA), + LocationData(LocationName.CATA_BLUE_KEY_SLIMES, RegionName.CATA_BOW_CAMPFIRE, LocationGroup.KEY_BLUE, Area.CATA), + LocationData(LocationName.CATA_BLUE_KEY_EYEBALLS, RegionName.CATA_CENTAUR, LocationGroup.KEY_BLUE, Area.CATA), LocationData(LocationName.TR_ADORNED_KEY, RegionName.TR_BOTTOM, LocationGroup.ITEM, Area.TR), LocationData(LocationName.TR_HP_1_BOTTOM, RegionName.TR_BOTTOM_LEFT, LocationGroup.HEALTH, Area.TR), LocationData(LocationName.TR_HP_2_TOP, RegionName.TR_LEFT, LocationGroup.HEALTH, Area.TR), @@ -799,13 +719,9 @@ class LocationData: LocationData(LocationName.TR_BRAM, RegionName.TR_BRAM, LocationGroup.CHARACTER, Area.TR), LocationData(LocationName.GT_ELEVATOR_2, RegionName.GT_BOSS, LocationGroup.ELEVATOR, Area.GT), LocationData(LocationName.GT_SWITCH_2ND_ROOM, RegionName.GT_ENTRANCE, LocationGroup.SWITCH, Area.GT), - LocationData( - LocationName.GT_SWITCH_1ST_CYCLOPS, RegionName.GT_GORGONHEART, LocationGroup.SWITCH, Area.GT - ), + LocationData(LocationName.GT_SWITCH_1ST_CYCLOPS, RegionName.GT_GORGONHEART, LocationGroup.SWITCH, Area.GT), LocationData(LocationName.GT_SWITCH_SPIKE_TUNNEL, RegionName.GT_TOP_LEFT, LocationGroup.SWITCH, Area.GT), - LocationData( - LocationName.GT_SWITCH_BUTT_ACCESS, RegionName.GT_SPIKE_TUNNEL_SWITCH, LocationGroup.SWITCH, Area.GT - ), + LocationData(LocationName.GT_SWITCH_BUTT_ACCESS, RegionName.GT_SPIKE_TUNNEL_SWITCH, LocationGroup.SWITCH, Area.GT), LocationData(LocationName.GT_SWITCH_GH, RegionName.GT_GORGONHEART, LocationGroup.SWITCH, Area.GT), LocationData( LocationName.GT_SWITCH_UPPER_PATH_BLOCKS, @@ -820,31 +736,17 @@ class LocationData: Area.GT, ), LocationData(LocationName.GT_SWITCH_CROSSES, RegionName.GT_LEFT, LocationGroup.SWITCH, Area.GT), - LocationData( - LocationName.GT_SWITCH_GH_SHORTCUT, RegionName.GT_GORGONHEART, LocationGroup.SWITCH, Area.GT - ), + LocationData(LocationName.GT_SWITCH_GH_SHORTCUT, RegionName.GT_GORGONHEART, LocationGroup.SWITCH, Area.GT), LocationData(LocationName.GT_SWITCH_ARIAS_PATH, RegionName.GT_TOP_LEFT, LocationGroup.SWITCH, Area.GT), - LocationData( - LocationName.GT_SWITCH_SWORD_ACCESS, RegionName.GT_SWORD_FORK, LocationGroup.SWITCH, Area.GT - ), - LocationData( - LocationName.GT_SWITCH_SWORD_BACKTRACK, RegionName.GT_SWORD_FORK, LocationGroup.SWITCH, Area.GT - ), + LocationData(LocationName.GT_SWITCH_SWORD_ACCESS, RegionName.GT_SWORD_FORK, LocationGroup.SWITCH, Area.GT), + LocationData(LocationName.GT_SWITCH_SWORD_BACKTRACK, RegionName.GT_SWORD_FORK, LocationGroup.SWITCH, Area.GT), LocationData(LocationName.GT_SWITCH_SWORD, RegionName.GT_SWORD, LocationGroup.SWITCH, Area.GT), - LocationData( - LocationName.GT_SWITCH_UPPER_ARIAS, RegionName.GT_ARIAS_SWORD_SWITCH, LocationGroup.SWITCH, Area.GT - ), + LocationData(LocationName.GT_SWITCH_UPPER_ARIAS, RegionName.GT_ARIAS_SWORD_SWITCH, LocationGroup.SWITCH, Area.GT), LocationData(LocationName.GT_CRYSTAL_LADDER, RegionName.GT_LADDER_SWITCH, LocationGroup.SWITCH, Area.GT), LocationData(LocationName.GT_CRYSTAL_ROTA, RegionName.GT_UPPER_PATH, LocationGroup.SWITCH, Area.GT), - LocationData( - LocationName.GT_CRYSTAL_OLD_MAN_1, RegionName.GT_OLD_MAN_FORK, LocationGroup.SWITCH, Area.GT - ), - LocationData( - LocationName.GT_CRYSTAL_OLD_MAN_2, RegionName.GT_OLD_MAN_FORK, LocationGroup.SWITCH, Area.GT - ), - LocationData( - LocationName.MECH_ELEVATOR_1, RegionName.MECH_ZEEK_CONNECTION, LocationGroup.ELEVATOR, Area.MECH - ), + LocationData(LocationName.GT_CRYSTAL_OLD_MAN_1, RegionName.GT_OLD_MAN_FORK, LocationGroup.SWITCH, Area.GT), + LocationData(LocationName.GT_CRYSTAL_OLD_MAN_2, RegionName.GT_OLD_MAN_FORK, LocationGroup.SWITCH, Area.GT), + LocationData(LocationName.MECH_ELEVATOR_1, RegionName.MECH_ZEEK_CONNECTION, LocationGroup.ELEVATOR, Area.MECH), LocationData(LocationName.MECH_ELEVATOR_2, RegionName.MECH_BOSS, LocationGroup.ELEVATOR, Area.MECH), LocationData(LocationName.MECH_SWITCH_WATCHER, RegionName.MECH_ROOTS, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_SWITCH_CHAINS, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH), @@ -860,12 +762,8 @@ class LocationData: LocationGroup.SWITCH, Area.MECH, ), - LocationData( - LocationName.MECH_SWITCH_SPLIT_PATH, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_SWITCH_SNAKE_1, RegionName.MECH_BOTTOM_CAMPFIRE, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_SPLIT_PATH, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_SWITCH_SNAKE_1, RegionName.MECH_BOTTOM_CAMPFIRE, LocationGroup.SWITCH, Area.MECH), LocationData( LocationName.MECH_SWITCH_BOOTS_ACCESS, RegionName.MECH_BOOTS_CONNECTION, @@ -878,23 +776,13 @@ class LocationData: LocationGroup.SWITCH, Area.MECH, ), - LocationData( - LocationName.MECH_SWITCH_UPPER_VOID_DROP, RegionName.MECH_RIGHT, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_SWITCH_UPPER_VOID, RegionName.MECH_UPPER_VOID, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_UPPER_VOID_DROP, RegionName.MECH_RIGHT, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_SWITCH_UPPER_VOID, RegionName.MECH_UPPER_VOID, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_SWITCH_LINUS, RegionName.MECH_LINUS, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_SWITCH_TO_BOSS_2, RegionName.MECH_BOSS_SWITCHES, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_TO_BOSS_2, RegionName.MECH_BOSS_SWITCHES, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_SWITCH_POTS, RegionName.MECH_BELOW_POTS, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_SWITCH_MAZE_BACKDOOR, RegionName.HOTP_FALL_BOTTOM, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_SWITCH_TO_BOSS_1, RegionName.MECH_BOSS_SWITCHES, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_MAZE_BACKDOOR, RegionName.HOTP_FALL_BOTTOM, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_SWITCH_TO_BOSS_1, RegionName.MECH_BOSS_SWITCHES, LocationGroup.SWITCH, Area.MECH), LocationData( LocationName.MECH_SWITCH_BLOCK_STAIRS, RegionName.MECH_CLOAK_CONNECTION, @@ -907,66 +795,34 @@ class LocationData: LocationGroup.SWITCH, Area.MECH, ), - LocationData( - LocationName.MECH_SWITCH_BOOTS_LOWER, RegionName.MECH_BOOTS_LOWER, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_SWITCH_CHAINS_GAP, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_SWITCH_LOWER_KEY, RegionName.MECH_SWORD_CONNECTION, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_SWITCH_ARIAS, RegionName.MECH_ARIAS_EYEBALL, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_BOOTS_LOWER, RegionName.MECH_BOOTS_LOWER, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_SWITCH_CHAINS_GAP, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_SWITCH_LOWER_KEY, RegionName.MECH_SWORD_CONNECTION, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_SWITCH_ARIAS, RegionName.MECH_ARIAS_EYEBALL, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_SWITCH_SNAKE_2, RegionName.MECH_SNAKE, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_SWITCH_KEY_BLOCKS, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_KEY_BLOCKS, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_SWITCH_CANNON, RegionName.MECH_START, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_SWITCH_EYEBALL, RegionName.MECH_BELOW_POTS, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_SWITCH_EYEBALL, RegionName.MECH_BELOW_POTS, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_SWITCH_INVISIBLE, RegionName.MECH_RIGHT, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_CANNON, RegionName.MECH_START, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_LINUS, RegionName.MECH_START, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_CRYSTAL_LOWER, RegionName.MECH_SWORD_CONNECTION, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_CRYSTAL_TO_BOSS_3, RegionName.MECH_BOSS_CONNECTION, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_CRYSTAL_TRIPLE_1, RegionName.MECH_TRIPLE_SWITCHES, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_CRYSTAL_TRIPLE_2, RegionName.MECH_TRIPLE_SWITCHES, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_CRYSTAL_TRIPLE_3, RegionName.MECH_TRIPLE_SWITCHES, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_CRYSTAL_LOWER, RegionName.MECH_SWORD_CONNECTION, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_CRYSTAL_TO_BOSS_3, RegionName.MECH_BOSS_CONNECTION, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_CRYSTAL_TRIPLE_1, RegionName.MECH_TRIPLE_SWITCHES, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_CRYSTAL_TRIPLE_2, RegionName.MECH_TRIPLE_SWITCHES, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_CRYSTAL_TRIPLE_3, RegionName.MECH_TRIPLE_SWITCHES, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_TOP, RegionName.MECH_TOP, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_CRYSTAL_CLOAK, RegionName.MECH_CLOAK_CONNECTION, LocationGroup.SWITCH, Area.MECH - ), - LocationData( - LocationName.MECH_CRYSTAL_SLIMES, RegionName.MECH_BOSS_SWITCHES, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_CRYSTAL_CLOAK, RegionName.MECH_CLOAK_CONNECTION, LocationGroup.SWITCH, Area.MECH), + LocationData(LocationName.MECH_CRYSTAL_SLIMES, RegionName.MECH_BOSS_SWITCHES, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_TO_CD, RegionName.MECH_TOP, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_CAMPFIRE, RegionName.MECH_BK, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_1ST_ROOM, RegionName.MECH_START, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_OLD_MAN, RegionName.MECH_RIGHT, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_CRYSTAL_TOP_CHAINS, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_CRYSTAL_TOP_CHAINS, RegionName.MECH_CHAINS, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.MECH_CRYSTAL_BK, RegionName.MECH_BK, LocationGroup.SWITCH, Area.MECH), - LocationData( - LocationName.MECH_FACE_ABOVE_VOLANTIS, RegionName.MECH_BOSS, LocationGroup.SWITCH, Area.MECH - ), + LocationData(LocationName.MECH_FACE_ABOVE_VOLANTIS, RegionName.MECH_BOSS, LocationGroup.SWITCH, Area.MECH), LocationData(LocationName.HOTP_ELEVATOR, RegionName.HOTP_ELEVATOR, LocationGroup.ELEVATOR, Area.HOTP), - LocationData( - LocationName.HOTP_SWITCH_ROCK, RegionName.HOTP_AMULET_CONNECTION, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_SWITCH_ROCK, RegionName.HOTP_AMULET_CONNECTION, LocationGroup.SWITCH, Area.HOTP), LocationData( LocationName.HOTP_SWITCH_BELOW_START, RegionName.HOTP_START_BOTTOM_MID, @@ -975,52 +831,30 @@ class LocationData: ), LocationData(LocationName.HOTP_SWITCH_LEFT_2, RegionName.HOTP_START_MID, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_SWITCH_LEFT_1, RegionName.HOTP_START_MID, LocationGroup.SWITCH, Area.HOTP), - LocationData( - LocationName.HOTP_SWITCH_LOWER_SHORTCUT, RegionName.HOTP_LOWER, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_SWITCH_LOWER_SHORTCUT, RegionName.HOTP_LOWER, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_SWITCH_BELL, RegionName.HOTP_BELL, LocationGroup.SWITCH, Area.HOTP), - LocationData( - LocationName.HOTP_SWITCH_GHOST_BLOOD, RegionName.HOTP_GHOST_BLOOD, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_SWITCH_TELEPORTS, RegionName.HOTP_LOWER_ARIAS, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_SWITCH_WORM_PILLAR, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_SWITCH_TO_CLAW_1, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_SWITCH_TO_CLAW_2, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_SWITCH_CLAW_ACCESS, RegionName.HOTP_CLAW_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_SWITCH_GHOST_BLOOD, RegionName.HOTP_GHOST_BLOOD, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_SWITCH_TELEPORTS, RegionName.HOTP_LOWER_ARIAS, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_SWITCH_WORM_PILLAR, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_SWITCH_TO_CLAW_1, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_SWITCH_TO_CLAW_2, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_SWITCH_CLAW_ACCESS, RegionName.HOTP_CLAW_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_SWITCH_GHOSTS, RegionName.HOTP_START_MID, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_SWITCH_LEFT_3, RegionName.HOTP_START_MID, LocationGroup.SWITCH, Area.HOTP), LocationData( LocationName.HOTP_SWITCH_ABOVE_OLD_MAN, RegionName.HOTP_ABOVE_OLD_MAN, LocationGroup.SWITCH, Area.HOTP ), - LocationData( - LocationName.HOTP_SWITCH_TO_ABOVE_OLD_MAN, RegionName.HOTP_TOP_LEFT, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_SWITCH_TP_PUZZLE, RegionName.HOTP_TP_PUZZLE, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_SWITCH_TO_ABOVE_OLD_MAN, RegionName.HOTP_TOP_LEFT, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_SWITCH_TP_PUZZLE, RegionName.HOTP_TP_PUZZLE, LocationGroup.SWITCH, Area.HOTP), LocationData( LocationName.HOTP_SWITCH_EYEBALL_SHORTCUT, RegionName.HOTP_WORM_SHORTCUT, LocationGroup.SWITCH, Area.HOTP, ), - LocationData( - LocationName.HOTP_SWITCH_BELL_ACCESS, RegionName.HOTP_BELL_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_SWITCH_BELL_ACCESS, RegionName.HOTP_BELL_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_SWITCH_1ST_ROOM, RegionName.HOTP_START, LocationGroup.SWITCH, Area.HOTP), - LocationData( - LocationName.HOTP_SWITCH_LEFT_BACKTRACK, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_SWITCH_LEFT_BACKTRACK, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP), LocationData( LocationName.HOTP_CRYSTAL_ROCK_ACCESS, RegionName.HOTP_MECH_VOID_CONNECTION, @@ -1034,81 +868,39 @@ class LocationData: Area.HOTP, ), LocationData(LocationName.HOTP_CRYSTAL_LOWER, RegionName.HOTP_LOWER, LocationGroup.SWITCH, Area.HOTP), - LocationData( - LocationName.HOTP_CRYSTAL_AFTER_CLAW, RegionName.HOTP_CLAW_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_CRYSTAL_AFTER_CLAW, RegionName.HOTP_CLAW_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_CRYSTAL_MAIDEN_1, RegionName.HOTP_MAIDEN, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_CRYSTAL_MAIDEN_2, RegionName.HOTP_MAIDEN, LocationGroup.SWITCH, Area.HOTP), - LocationData( - LocationName.HOTP_CRYSTAL_BELL_ACCESS, RegionName.HOTP_BELL_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_CRYSTAL_HEART, RegionName.HOTP_BOSS_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP - ), - LocationData( - LocationName.HOTP_CRYSTAL_BELOW_PUZZLE, RegionName.HOTP_TP_FALL_TOP, LocationGroup.SWITCH, Area.HOTP - ), + LocationData(LocationName.HOTP_CRYSTAL_BELL_ACCESS, RegionName.HOTP_BELL_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_CRYSTAL_HEART, RegionName.HOTP_BOSS_CAMPFIRE, LocationGroup.SWITCH, Area.HOTP), + LocationData(LocationName.HOTP_CRYSTAL_BELOW_PUZZLE, RegionName.HOTP_TP_FALL_TOP, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.HOTP_FACE_OLD_MAN, RegionName.HOTP_ELEVATOR, LocationGroup.SWITCH, Area.HOTP), LocationData(LocationName.ROA_ELEVATOR_1, RegionName.HOTP_BOSS, LocationGroup.ELEVATOR, Area.ROA), LocationData(LocationName.ROA_ELEVATOR_2, RegionName.ROA_ELEVATOR, LocationGroup.ELEVATOR, Area.ROA), - LocationData( - LocationName.ROA_SWITCH_ASCEND, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_AFTER_WORMS, RegionName.ROA_WORMS_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_RIGHT_PATH, RegionName.ROA_RIGHT_SWITCH_1, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_APEX_ACCESS, RegionName.ROA_APEX_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_SWITCH_ASCEND, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_AFTER_WORMS, RegionName.ROA_WORMS_CONNECTION, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_RIGHT_PATH, RegionName.ROA_RIGHT_SWITCH_1, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_APEX_ACCESS, RegionName.ROA_APEX_CONNECTION, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_ICARUS, RegionName.ROA_ELEVATOR, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_SWITCH_SHAFT_L, RegionName.ROA_MIDDLE_LADDER, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_SHAFT_R, RegionName.ROA_MIDDLE_LADDER, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_SWITCH_SHAFT_L, RegionName.ROA_MIDDLE_LADDER, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_SHAFT_R, RegionName.ROA_MIDDLE_LADDER, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_ELEVATOR, RegionName.ROA_ELEVATOR, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_SWITCH_SHAFT_DOWNWARDS, RegionName.ROA_SP_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_SWITCH_SHAFT_DOWNWARDS, RegionName.ROA_SP_CONNECTION, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_SPIDERS, RegionName.ROA_SPIDERS_2, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_DARK_ROOM, RegionName.ROA_ELEVATOR, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_SWITCH_ASCEND_SHORTCUT, RegionName.ROA_MIDDLE, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_1ST_SHORTCUT, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_SPIKE_CLIMB, RegionName.ROA_SPIKE_CLIMB, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_ABOVE_CENTAUR, RegionName.ROA_SP_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_SWITCH_ASCEND_SHORTCUT, RegionName.ROA_MIDDLE, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_1ST_SHORTCUT, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_SPIKE_CLIMB, RegionName.ROA_SPIKE_CLIMB, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_ABOVE_CENTAUR, RegionName.ROA_SP_CONNECTION, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_BLOOD_POT, RegionName.ROA_CENTAUR, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_WORMS, RegionName.ROA_WORMS, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_SWITCH_TRIPLE_1, RegionName.ROA_TRIPLE_SWITCH, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_TRIPLE_3, RegionName.ROA_TRIPLE_SWITCH, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_SWITCH_TRIPLE_1, RegionName.ROA_TRIPLE_SWITCH, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_TRIPLE_3, RegionName.ROA_TRIPLE_SWITCH, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_SWITCH_BABY_GORGON, RegionName.ROA_FLAMES, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_SWITCH_BOSS_ACCESS, RegionName.ROA_BOSS_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_BLOOD_POT_L, RegionName.ROA_BOSS_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_BLOOD_POT_R, RegionName.ROA_BOSS_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_SWITCH_LOWER_VOID, RegionName.ROA_LOWER_VOID, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_SWITCH_BOSS_ACCESS, RegionName.ROA_BOSS_CONNECTION, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_BLOOD_POT_L, RegionName.ROA_BOSS_CONNECTION, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_BLOOD_POT_R, RegionName.ROA_BOSS_CONNECTION, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_SWITCH_LOWER_VOID, RegionName.ROA_LOWER_VOID, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_CRYSTAL_1ST_ROOM, RegionName.ROA_START, LocationGroup.SWITCH, Area.ROA), LocationData( LocationName.ROA_CRYSTAL_BABY_GORGON, @@ -1116,79 +908,41 @@ class LocationData: LocationGroup.SWITCH, Area.ROA, ), - LocationData( - LocationName.ROA_CRYSTAL_LADDER_R, RegionName.ROA_RIGHT_SWITCH_2, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_CRYSTAL_LADDER_L, RegionName.ROA_LEFT_SWITCH, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_CRYSTAL_LADDER_R, RegionName.ROA_RIGHT_SWITCH_2, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_CRYSTAL_LADDER_L, RegionName.ROA_LEFT_SWITCH, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_CRYSTAL_CENTAUR, RegionName.ROA_CENTAUR, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_CRYSTAL_SPIKE_BALLS, RegionName.ROA_UPPER_VOID, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_CRYSTAL_SPIKE_BALLS, RegionName.ROA_UPPER_VOID, LocationGroup.SWITCH, Area.ROA), LocationData( LocationName.ROA_CRYSTAL_LEFT_ASCEND, RegionName.ROA_LEFT_ASCENT_CRYSTAL, LocationGroup.SWITCH, Area.ROA, ), - LocationData( - LocationName.ROA_CRYSTAL_SHAFT, RegionName.ROA_SP_CONNECTION, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_CRYSTAL_BRANCH_R, RegionName.ROA_RIGHT_BRANCH, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_CRYSTAL_BRANCH_L, RegionName.ROA_RIGHT_BRANCH, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_CRYSTAL_3_REAPERS, RegionName.ROA_TRIPLE_REAPER, LocationGroup.SWITCH, Area.ROA - ), - LocationData( - LocationName.ROA_CRYSTAL_TRIPLE_2, RegionName.ROA_TRIPLE_SWITCH, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_CRYSTAL_SHAFT, RegionName.ROA_SP_CONNECTION, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_CRYSTAL_BRANCH_R, RegionName.ROA_RIGHT_BRANCH, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_CRYSTAL_BRANCH_L, RegionName.ROA_RIGHT_BRANCH, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_CRYSTAL_3_REAPERS, RegionName.ROA_TRIPLE_REAPER, LocationGroup.SWITCH, Area.ROA), + LocationData(LocationName.ROA_CRYSTAL_TRIPLE_2, RegionName.ROA_TRIPLE_SWITCH, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.ROA_FACE_SPIDERS, RegionName.ROA_SPIDERS_1, LocationGroup.SWITCH, Area.ROA), - LocationData( - LocationName.ROA_FACE_BLUE_KEY, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.SWITCH, Area.ROA - ), + LocationData(LocationName.ROA_FACE_BLUE_KEY, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.SWITCH, Area.ROA), LocationData(LocationName.DARK_SWITCH, RegionName.DARK_START, LocationGroup.SWITCH, Area.DARK), LocationData(LocationName.APEX_ELEVATOR, RegionName.APEX, LocationGroup.ELEVATOR, Area.APEX), LocationData(LocationName.APEX_SWITCH, RegionName.APEX, LocationGroup.SWITCH, Area.APEX), - LocationData( - LocationName.CAVES_SWITCH_SKELETONS, RegionName.CAVES_UPPER, LocationGroup.SWITCH, Area.CAVES - ), - LocationData( - LocationName.CAVES_SWITCH_CATA_ACCESS_1, RegionName.CAVES_LOWER, LocationGroup.SWITCH, Area.CAVES - ), - LocationData( - LocationName.CAVES_SWITCH_CATA_ACCESS_2, RegionName.CAVES_LOWER, LocationGroup.SWITCH, Area.CAVES - ), - LocationData( - LocationName.CAVES_SWITCH_CATA_ACCESS_3, RegionName.CAVES_LOWER, LocationGroup.SWITCH, Area.CAVES - ), + LocationData(LocationName.CAVES_SWITCH_SKELETONS, RegionName.CAVES_UPPER, LocationGroup.SWITCH, Area.CAVES), + LocationData(LocationName.CAVES_SWITCH_CATA_ACCESS_1, RegionName.CAVES_LOWER, LocationGroup.SWITCH, Area.CAVES), + LocationData(LocationName.CAVES_SWITCH_CATA_ACCESS_2, RegionName.CAVES_LOWER, LocationGroup.SWITCH, Area.CAVES), + LocationData(LocationName.CAVES_SWITCH_CATA_ACCESS_3, RegionName.CAVES_LOWER, LocationGroup.SWITCH, Area.CAVES), LocationData(LocationName.CAVES_FACE_1ST_ROOM, RegionName.CAVES_START, LocationGroup.SWITCH, Area.CAVES), LocationData(LocationName.CATA_ELEVATOR_1, RegionName.CATA_ELEVATOR, LocationGroup.ELEVATOR, Area.CATA), LocationData(LocationName.CATA_ELEVATOR_2, RegionName.CATA_BOSS, LocationGroup.ELEVATOR, Area.CATA), LocationData(LocationName.CATA_SWITCH_ELEVATOR, RegionName.CATA_TOP, LocationGroup.SWITCH, Area.CATA), - LocationData( - LocationName.CATA_SWITCH_SHORTCUT, RegionName.CATA_VERTICAL_SHORTCUT, LocationGroup.SWITCH, Area.CATA - ), + LocationData(LocationName.CATA_SWITCH_SHORTCUT, RegionName.CATA_VERTICAL_SHORTCUT, LocationGroup.SWITCH, Area.CATA), LocationData(LocationName.CATA_SWITCH_TOP, RegionName.CATA_TOP, LocationGroup.SWITCH, Area.CATA), - LocationData( - LocationName.CATA_SWITCH_CLAW_1, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_SWITCH_CLAW_2, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_SWITCH_WATER_1, RegionName.CATA_DOUBLE_SWITCH, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_SWITCH_WATER_2, RegionName.CATA_DOUBLE_SWITCH, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_SWITCH_DEV_ROOM, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.SWITCH, Area.CATA - ), + LocationData(LocationName.CATA_SWITCH_CLAW_1, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_SWITCH_CLAW_2, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_SWITCH_WATER_1, RegionName.CATA_DOUBLE_SWITCH, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_SWITCH_WATER_2, RegionName.CATA_DOUBLE_SWITCH, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_SWITCH_DEV_ROOM, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.SWITCH, Area.CATA), LocationData( LocationName.CATA_SWITCH_AFTER_BLUE_DOOR, RegionName.CATA_BLUE_EYE_DOOR, @@ -1198,9 +952,7 @@ class LocationData: LocationData( LocationName.CATA_SWITCH_SHORTCUT_ACCESS, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA ), - LocationData( - LocationName.CATA_SWITCH_LADDER_BLOCKS, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA - ), + LocationData(LocationName.CATA_SWITCH_LADDER_BLOCKS, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA), LocationData( LocationName.CATA_SWITCH_MID_SHORTCUT, RegionName.CATA_VERTICAL_SHORTCUT, @@ -1208,24 +960,14 @@ class LocationData: Area.CATA, ), LocationData(LocationName.CATA_SWITCH_1ST_ROOM, RegionName.CATA_START, LocationGroup.SWITCH, Area.CATA), - LocationData( - LocationName.CATA_SWITCH_FLAMES_2, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_SWITCH_FLAMES_1, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_CRYSTAL_POISON_ROOTS, RegionName.CATA_POISON_ROOTS, LocationGroup.SWITCH, Area.CATA - ), - LocationData( - LocationName.CATA_FACE_AFTER_BOW, RegionName.CATA_BOW_CAMPFIRE, LocationGroup.SWITCH, Area.CATA - ), + LocationData(LocationName.CATA_SWITCH_FLAMES_2, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_SWITCH_FLAMES_1, RegionName.CATA_FLAMES_FORK, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_CRYSTAL_POISON_ROOTS, RegionName.CATA_POISON_ROOTS, LocationGroup.SWITCH, Area.CATA), + LocationData(LocationName.CATA_FACE_AFTER_BOW, RegionName.CATA_BOW_CAMPFIRE, LocationGroup.SWITCH, Area.CATA), LocationData(LocationName.CATA_FACE_BOW, RegionName.CATA_BOW, LocationGroup.SWITCH, Area.CATA), LocationData(LocationName.CATA_FACE_X4, RegionName.CATA_4_FACES, LocationGroup.SWITCH, Area.CATA), LocationData(LocationName.CATA_FACE_CAMPFIRE, RegionName.CATA_BOSS, LocationGroup.SWITCH, Area.CATA), - LocationData( - LocationName.CATA_FACE_DOUBLE_DOOR, RegionName.CATA_DOUBLE_DOOR, LocationGroup.SWITCH, Area.CATA - ), + LocationData(LocationName.CATA_FACE_DOUBLE_DOOR, RegionName.CATA_DOUBLE_DOOR, LocationGroup.SWITCH, Area.CATA), LocationData(LocationName.CATA_FACE_BOTTOM, RegionName.CATA_DOUBLE_DOOR, LocationGroup.SWITCH, Area.CATA), LocationData(LocationName.TR_ELEVATOR, RegionName.TR_START, LocationGroup.ELEVATOR, Area.TR), LocationData(LocationName.TR_SWITCH_ADORNED_L, RegionName.TR_BOTTOM, LocationGroup.SWITCH, Area.TR), @@ -1244,33 +986,21 @@ class LocationData: LocationData(LocationName.CD_CRYSTAL_START, RegionName.CD_START, LocationGroup.SWITCH, Area.CD), LocationData(LocationName.CD_CRYSTAL_CAMPFIRE, RegionName.CD_CAMPFIRE_3, LocationGroup.SWITCH, Area.CD), LocationData(LocationName.CD_CRYSTAL_STEPS, RegionName.CD_STEPS, LocationGroup.SWITCH, Area.CD), - LocationData( - LocationName.CATH_SWITCH_BOTTOM, RegionName.CATH_START_RIGHT, LocationGroup.SWITCH, Area.CATH - ), - LocationData( - LocationName.CATH_SWITCH_BESIDE_SHAFT, RegionName.CATH_SHAFT_ACCESS, LocationGroup.SWITCH, Area.CATH - ), + LocationData(LocationName.CATH_SWITCH_BOTTOM, RegionName.CATH_START_RIGHT, LocationGroup.SWITCH, Area.CATH), + LocationData(LocationName.CATH_SWITCH_BESIDE_SHAFT, RegionName.CATH_SHAFT_ACCESS, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_SWITCH_TOP_CAMPFIRE, RegionName.CATH_TOP, LocationGroup.SWITCH, Area.CATH), - LocationData( - LocationName.CATH_CRYSTAL_1ST_ROOM, RegionName.CATH_START_TOP_LEFT, LocationGroup.SWITCH, Area.CATH - ), - LocationData( - LocationName.CATH_CRYSTAL_SHAFT, RegionName.CATH_LEFT_SHAFT, LocationGroup.SWITCH, Area.CATH - ), + LocationData(LocationName.CATH_CRYSTAL_1ST_ROOM, RegionName.CATH_START_TOP_LEFT, LocationGroup.SWITCH, Area.CATH), + LocationData(LocationName.CATH_CRYSTAL_SHAFT, RegionName.CATH_LEFT_SHAFT, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_CRYSTAL_SPIKE_PIT, RegionName.CATH_TOP, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_CRYSTAL_TOP_L, RegionName.CATH_TOP, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_CRYSTAL_TOP_R, RegionName.CATH_TOP, LocationGroup.SWITCH, Area.CATH), - LocationData( - LocationName.CATH_CRYSTAL_SHAFT_ACCESS, RegionName.CATH_SHAFT_ACCESS, LocationGroup.SWITCH, Area.CATH - ), + LocationData(LocationName.CATH_CRYSTAL_SHAFT_ACCESS, RegionName.CATH_SHAFT_ACCESS, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_CRYSTAL_ORBS, RegionName.CATH_ORB_ROOM, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_FACE_LEFT, RegionName.CATH_START_LEFT, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.CATH_FACE_RIGHT, RegionName.CATH_START_LEFT, LocationGroup.SWITCH, Area.CATH), LocationData(LocationName.SP_SWITCH_DOUBLE_DOORS, RegionName.SP_HEARTS, LocationGroup.SWITCH, Area.SP), LocationData(LocationName.SP_SWITCH_BUBBLES, RegionName.SP_CAMPFIRE_1, LocationGroup.SWITCH, Area.SP), - LocationData( - LocationName.SP_SWITCH_AFTER_STAR, RegionName.SP_STAR_CONNECTION, LocationGroup.SWITCH, Area.SP - ), + LocationData(LocationName.SP_SWITCH_AFTER_STAR, RegionName.SP_STAR_CONNECTION, LocationGroup.SWITCH, Area.SP), LocationData(LocationName.SP_CRYSTAL_BLOCKS, RegionName.SP_START, LocationGroup.SWITCH, Area.SP), LocationData(LocationName.SP_CRYSTAL_STAR, RegionName.SP_SHAFT, LocationGroup.SWITCH, Area.SP), LocationData(LocationName.MECH_CYCLOPS, RegionName.MECH_ZEEK, LocationGroup.ITEM, Area.MECH), @@ -1280,176 +1010,84 @@ class LocationData: LocationData(LocationName.GT_CANDLE_BOSS, RegionName.GT_BOSS, LocationGroup.CANDLE, Area.GT), LocationData(LocationName.GT_CANDLE_BOTTOM, RegionName.GT_BOTTOM, LocationGroup.CANDLE, Area.GT), LocationData(LocationName.MECH_CANDLE_ROOTS, RegionName.MECH_ROOTS, LocationGroup.CANDLE, Area.MECH), - LocationData( - LocationName.MECH_CANDLE_BOTTOM, RegionName.MECH_BOTTOM_CAMPFIRE, LocationGroup.CANDLE, Area.MECH - ), - LocationData( - LocationName.MECH_CANDLE_CHAINS, RegionName.MECH_CHAINS_CANDLE, LocationGroup.CANDLE, Area.MECH - ), + LocationData(LocationName.MECH_CANDLE_BOTTOM, RegionName.MECH_BOTTOM_CAMPFIRE, LocationGroup.CANDLE, Area.MECH), + LocationData(LocationName.MECH_CANDLE_CHAINS, RegionName.MECH_CHAINS_CANDLE, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.MECH_CANDLE_RIGHT, RegionName.MECH_SPLIT_PATH, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.MECH_CANDLE_POTS, RegionName.MECH_BELOW_POTS, LocationGroup.CANDLE, Area.MECH), - LocationData( - LocationName.MECH_CANDLE_BOSS_1, RegionName.MECH_BOSS_CONNECTION, LocationGroup.CANDLE, Area.MECH - ), + LocationData(LocationName.MECH_CANDLE_BOSS_1, RegionName.MECH_BOSS_CONNECTION, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.MECH_CANDLE_BOSS_2, RegionName.MECH_BOSS, LocationGroup.CANDLE, Area.MECH), - LocationData( - LocationName.MECH_CANDLE_SLIMES, RegionName.MECH_CLOAK_CONNECTION, LocationGroup.CANDLE, Area.MECH - ), - LocationData( - LocationName.MECH_CANDLE_ZEEK, RegionName.MECH_ZEEK_CONNECTION, LocationGroup.CANDLE, Area.MECH - ), - LocationData( - LocationName.MECH_CANDLE_MAZE_BACKDOOR, RegionName.HOTP_FALL_BOTTOM, LocationGroup.CANDLE, Area.MECH - ), - LocationData( - LocationName.MECH_CANDLE_CD_ACCESS_1, RegionName.MECH_CD_ACCESS, LocationGroup.CANDLE, Area.MECH - ), - LocationData( - LocationName.MECH_CANDLE_CD_ACCESS_2, RegionName.MECH_CD_ACCESS, LocationGroup.CANDLE, Area.MECH - ), - LocationData( - LocationName.MECH_CANDLE_CD_ACCESS_3, RegionName.MECH_CD_ACCESS, LocationGroup.CANDLE, Area.MECH - ), + LocationData(LocationName.MECH_CANDLE_SLIMES, RegionName.MECH_CLOAK_CONNECTION, LocationGroup.CANDLE, Area.MECH), + LocationData(LocationName.MECH_CANDLE_ZEEK, RegionName.MECH_ZEEK_CONNECTION, LocationGroup.CANDLE, Area.MECH), + LocationData(LocationName.MECH_CANDLE_MAZE_BACKDOOR, RegionName.HOTP_FALL_BOTTOM, LocationGroup.CANDLE, Area.MECH), + LocationData(LocationName.MECH_CANDLE_CD_ACCESS_1, RegionName.MECH_CD_ACCESS, LocationGroup.CANDLE, Area.MECH), + LocationData(LocationName.MECH_CANDLE_CD_ACCESS_2, RegionName.MECH_CD_ACCESS, LocationGroup.CANDLE, Area.MECH), + LocationData(LocationName.MECH_CANDLE_CD_ACCESS_3, RegionName.MECH_CD_ACCESS, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.MECH_CANDLE_1ST_ROOM, RegionName.MECH_START, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.MECH_CANDLE_BK, RegionName.MECH_BK, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.MECH_CANDLE_CAMPFIRE_R, RegionName.MECH_RIGHT, LocationGroup.CANDLE, Area.MECH), LocationData(LocationName.HOTP_CANDLE_1ST_ROOM, RegionName.HOTP_START, LocationGroup.CANDLE, Area.HOTP), LocationData(LocationName.HOTP_CANDLE_LOWER, RegionName.HOTP_LOWER, LocationGroup.CANDLE, Area.HOTP), - LocationData( - LocationName.HOTP_CANDLE_BELL, RegionName.HOTP_BELL_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP - ), + LocationData(LocationName.HOTP_CANDLE_BELL, RegionName.HOTP_BELL_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP), LocationData(LocationName.HOTP_CANDLE_EYEBALL, RegionName.HOTP_EYEBALL, LocationGroup.CANDLE, Area.HOTP), LocationData(LocationName.HOTP_CANDLE_OLD_MAN, RegionName.HOTP_ELEVATOR, LocationGroup.CANDLE, Area.HOTP), - LocationData( - LocationName.HOTP_CANDLE_BEFORE_CLAW, RegionName.HOTP_TOP_LEFT, LocationGroup.CANDLE, Area.HOTP - ), + LocationData(LocationName.HOTP_CANDLE_BEFORE_CLAW, RegionName.HOTP_TOP_LEFT, LocationGroup.CANDLE, Area.HOTP), LocationData( LocationName.HOTP_CANDLE_CLAW_CAMPFIRE, RegionName.HOTP_CLAW_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP ), - LocationData( - LocationName.HOTP_CANDLE_TP_PUZZLE, RegionName.HOTP_BOSS_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_BOSS, RegionName.HOTP_BOSS_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_TP_FALL, RegionName.HOTP_TP_FALL_TOP, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_UPPER_VOID_1, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_UPPER_VOID_2, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_UPPER_VOID_3, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_UPPER_VOID_4, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP - ), - LocationData( - LocationName.HOTP_CANDLE_ELEVATOR, RegionName.HOTP_ELEVATOR, LocationGroup.CANDLE, Area.HOTP - ), + LocationData(LocationName.HOTP_CANDLE_TP_PUZZLE, RegionName.HOTP_BOSS_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_BOSS, RegionName.HOTP_BOSS_CAMPFIRE, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_TP_FALL, RegionName.HOTP_TP_FALL_TOP, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_UPPER_VOID_1, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_UPPER_VOID_2, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_UPPER_VOID_3, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_UPPER_VOID_4, RegionName.HOTP_UPPER_VOID, LocationGroup.CANDLE, Area.HOTP), + LocationData(LocationName.HOTP_CANDLE_ELEVATOR, RegionName.HOTP_ELEVATOR, LocationGroup.CANDLE, Area.HOTP), LocationData(LocationName.ROA_CANDLE_1ST_ROOM, RegionName.ROA_START, LocationGroup.CANDLE, Area.ROA), - LocationData( - LocationName.ROA_CANDLE_3_REAPERS, RegionName.ROA_LEFT_ASCENT, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_MIDDLE_CAMPFIRE, RegionName.ROA_MIDDLE, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_LADDER_BOTTOM, RegionName.ROA_MIDDLE, LocationGroup.CANDLE, Area.ROA - ), + LocationData(LocationName.ROA_CANDLE_3_REAPERS, RegionName.ROA_LEFT_ASCENT, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_MIDDLE_CAMPFIRE, RegionName.ROA_MIDDLE, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_LADDER_BOTTOM, RegionName.ROA_MIDDLE, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_SHAFT, RegionName.ROA_UPPER_VOID, LocationGroup.CANDLE, Area.ROA), - LocationData( - LocationName.ROA_CANDLE_SHAFT_TOP, RegionName.ROA_SP_CONNECTION, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_ABOVE_CENTAUR, RegionName.ROA_SP_CONNECTION, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_BABY_GORGON, RegionName.ROA_ELEVATOR, LocationGroup.CANDLE, Area.ROA - ), + LocationData(LocationName.ROA_CANDLE_SHAFT_TOP, RegionName.ROA_SP_CONNECTION, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_ABOVE_CENTAUR, RegionName.ROA_SP_CONNECTION, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_BABY_GORGON, RegionName.ROA_ELEVATOR, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_TOP_CENTAUR, RegionName.ROA_CENTAUR, LocationGroup.CANDLE, Area.ROA), - LocationData( - LocationName.ROA_CANDLE_HIDDEN_1, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_HIDDEN_2, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_HIDDEN_3, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_HIDDEN_4, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_HIDDEN_5, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_BOTTOM_ASCEND, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.CANDLE, Area.ROA - ), + LocationData(LocationName.ROA_CANDLE_HIDDEN_1, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_HIDDEN_2, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_HIDDEN_3, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_HIDDEN_4, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_HIDDEN_5, RegionName.ROA_MIDDLE_LADDER, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_BOTTOM_ASCEND, RegionName.ROA_BOTTOM_ASCEND, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_BRANCH, RegionName.ROA_RIGHT_BRANCH, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_ICARUS_1, RegionName.ROA_ICARUS, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_ICARUS_2, RegionName.ROA_ICARUS, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_ELEVATOR, RegionName.ROA_ELEVATOR, LocationGroup.CANDLE, Area.ROA), - LocationData( - LocationName.ROA_CANDLE_ELEVATOR_CAMPFIRE, RegionName.ROA_ELEVATOR, LocationGroup.CANDLE, Area.ROA - ), + LocationData(LocationName.ROA_CANDLE_ELEVATOR_CAMPFIRE, RegionName.ROA_ELEVATOR, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_BOSS_1, RegionName.ROA_BOSS, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_BOSS_2, RegionName.ROA_BOSS, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_SPIDERS, RegionName.ROA_SPIDERS_1, LocationGroup.CANDLE, Area.ROA), - LocationData( - LocationName.ROA_CANDLE_SPIKE_BALLS, RegionName.ROA_UPPER_VOID, LocationGroup.CANDLE, Area.ROA - ), - LocationData( - LocationName.ROA_CANDLE_LADDER_R, RegionName.ROA_RIGHT_SWITCH_CANDLE, LocationGroup.CANDLE, Area.ROA - ), + LocationData(LocationName.ROA_CANDLE_SPIKE_BALLS, RegionName.ROA_UPPER_VOID, LocationGroup.CANDLE, Area.ROA), + LocationData(LocationName.ROA_CANDLE_LADDER_R, RegionName.ROA_RIGHT_SWITCH_CANDLE, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.ROA_CANDLE_ARENA, RegionName.ROA_ARENA, LocationGroup.CANDLE, Area.ROA), LocationData(LocationName.APEX_CANDLE_ELEVATOR, RegionName.APEX, LocationGroup.CANDLE, Area.APEX), - LocationData( - LocationName.APEX_CANDLE_CHALICE_1, RegionName.APEX_CENTAUR_ACCESS, LocationGroup.CANDLE, Area.APEX - ), - LocationData( - LocationName.APEX_CANDLE_CHALICE_2, RegionName.APEX_CENTAUR_ACCESS, LocationGroup.CANDLE, Area.APEX - ), - LocationData( - LocationName.APEX_CANDLE_CHALICE_3, RegionName.APEX_CENTAUR_ACCESS, LocationGroup.CANDLE, Area.APEX - ), + LocationData(LocationName.APEX_CANDLE_CHALICE_1, RegionName.APEX_CENTAUR_ACCESS, LocationGroup.CANDLE, Area.APEX), + LocationData(LocationName.APEX_CANDLE_CHALICE_2, RegionName.APEX_CENTAUR_ACCESS, LocationGroup.CANDLE, Area.APEX), + LocationData(LocationName.APEX_CANDLE_CHALICE_3, RegionName.APEX_CENTAUR_ACCESS, LocationGroup.CANDLE, Area.APEX), LocationData(LocationName.APEX_CANDLE_GARG_1, RegionName.APEX, LocationGroup.CANDLE, Area.APEX), LocationData(LocationName.APEX_CANDLE_GARG_2, RegionName.APEX, LocationGroup.CANDLE, Area.APEX), LocationData(LocationName.APEX_CANDLE_GARG_3, RegionName.APEX, LocationGroup.CANDLE, Area.APEX), LocationData(LocationName.APEX_CANDLE_GARG_4, RegionName.APEX, LocationGroup.CANDLE, Area.APEX), LocationData(LocationName.CATA_CANDLE_1ST_ROOM, RegionName.CATA_START, LocationGroup.CANDLE, Area.CATA), LocationData(LocationName.CATA_CANDLE_ORB_MULTI, RegionName.CATA_MULTI, LocationGroup.CANDLE, Area.CATA), - LocationData( - LocationName.CATA_CANDLE_AFTER_BOW, RegionName.CATA_EYEBALL_BONES, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_DEV_ROOM, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_GRIFFON, RegionName.CATA_DOUBLE_SWITCH, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_SHORTCUT, RegionName.CATA_VERTICAL_SHORTCUT, LocationGroup.CANDLE, Area.CATA - ), + LocationData(LocationName.CATA_CANDLE_AFTER_BOW, RegionName.CATA_EYEBALL_BONES, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_DEV_ROOM, RegionName.CATA_SNAKE_MUSHROOMS, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_GRIFFON, RegionName.CATA_DOUBLE_SWITCH, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_SHORTCUT, RegionName.CATA_VERTICAL_SHORTCUT, LocationGroup.CANDLE, Area.CATA), LocationData(LocationName.CATA_CANDLE_PRISON, RegionName.CATA_BOSS, LocationGroup.CANDLE, Area.CATA), - LocationData( - LocationName.CATA_CANDLE_ABOVE_ROOTS_1, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_ABOVE_ROOTS_2, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_ABOVE_ROOTS_3, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_ABOVE_ROOTS_4, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA - ), - LocationData( - LocationName.CATA_CANDLE_ABOVE_ROOTS_5, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA - ), + LocationData(LocationName.CATA_CANDLE_ABOVE_ROOTS_1, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_ABOVE_ROOTS_2, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_ABOVE_ROOTS_3, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_ABOVE_ROOTS_4, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA), + LocationData(LocationName.CATA_CANDLE_ABOVE_ROOTS_5, RegionName.CATA_ABOVE_ROOTS, LocationGroup.CANDLE, Area.CATA), LocationData(LocationName.CATA_CANDLE_VOID_R_1, RegionName.CATA_VOID_R, LocationGroup.CANDLE, Area.CATA), LocationData(LocationName.CATA_CANDLE_VOID_R_2, RegionName.CATA_VOID_R, LocationGroup.CANDLE, Area.CATA), LocationData(LocationName.TR_CANDLE_1ST_ROOM_1, RegionName.TR_START, LocationGroup.CANDLE, Area.TR), @@ -1464,9 +1102,7 @@ class LocationData: ) location_table = {location.name.value: location for location in ALL_LOCATIONS} -location_name_to_id: dict[str, int] = { - data.name.value: i for i, data in enumerate(ALL_LOCATIONS, start=BASE_ID) -} +location_name_to_id: dict[str, int] = {data.name.value: i for i, data in enumerate(ALL_LOCATIONS, start=BASE_ID)} def get_location_group(location_name: str) -> LocationGroup: diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 1a2b498e2fa1..b8326df146a4 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -396,7 +396,7 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": @dataclasses.dataclass(init=False) class HasWhite(ToggleRule, game=GAME_NAME): - option_cls = RandomizeWhiteKeys + option_cls: "ClassVar[type[Option[int]]]" = RandomizeWhiteKeys def __init__( self, @@ -405,12 +405,12 @@ def __init__( options: "Iterable[rule_builder.OptionFilter[Any]]" = (), ) -> None: super().__init__(*doors, options=options) - self.otherwise = otherwise + self.otherwise: bool = otherwise @dataclasses.dataclass(init=False) class HasBlue(ToggleRule, game=GAME_NAME): - option_cls = RandomizeBlueKeys + option_cls: "ClassVar[type[Option[int]]]" = RandomizeBlueKeys def __init__( self, @@ -419,12 +419,12 @@ def __init__( options: "Iterable[rule_builder.OptionFilter[Any]]" = (), ) -> None: super().__init__(*doors, options=options) - self.otherwise = otherwise + self.otherwise: bool = otherwise @dataclasses.dataclass(init=False) class HasRed(ToggleRule, game=GAME_NAME): - option_cls = RandomizeRedKeys + option_cls: "ClassVar[type[Option[int]]]" = RandomizeRedKeys def __init__( self, @@ -433,12 +433,12 @@ def __init__( options: "Iterable[rule_builder.OptionFilter[Any]]" = (), ) -> None: super().__init__(*doors, options=options) - self.otherwise = otherwise + self.otherwise: bool = otherwise @dataclasses.dataclass(init=False) class HasSwitch(ToggleRule, game=GAME_NAME): - option_cls = RandomizeSwitches + option_cls: "ClassVar[type[Option[int]]]" = RandomizeSwitches def __init__( self, @@ -447,7 +447,7 @@ def __init__( options: "Iterable[rule_builder.OptionFilter[Any]]" = (), ) -> None: super().__init__(*switches, options=options) - self.otherwise = otherwise + self.otherwise: bool = otherwise @dataclasses.dataclass(init=False) @@ -486,7 +486,7 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": class Resolved(rule_builder.Wrapper.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: - return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child.test(state) + return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child(state) @override def item_dependencies(self) -> dict[str, set[int]]: diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 085e2d84fe55..ce37ca2fe83b 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -110,9 +110,6 @@ class AstalonWebWorld(WebWorld): ] -# TODO: Wrap rule, connect helper, better world typing (generic) - - class AstalonWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] """ Uphold your pact with the Titan of Death, Epimetheus! @@ -419,7 +416,7 @@ def create_items(self) -> None: @override def set_rules(self) -> None: - self.register_location_dependencies() + self.register_dependencies() @cached_property def filler_item_names(self) -> tuple[str, ...]: From afee42a79b32ac03ebc527fe92f5477e955503c3 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 9 Jul 2025 00:56:50 -0400 Subject: [PATCH 063/135] fix disabling caching --- rule_builder.py | 99 ++++++++++++--------- test/general/test_rule_builder.py | 138 ++++++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 49 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 5f90cbff2736..ff477d1d67a1 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -40,6 +40,12 @@ class RuleWorldMixin(World): completion_rule: "Rule.Resolved | None" = None """The resolved rule used for the completion condition of this world""" + true_rule: "Rule.Resolved" + """A pre-initialized rule for this world that always returns True""" + + false_rule: "Rule.Resolved" + """A pre-initialized rule for this world that always returns False""" + item_mapping: ClassVar[dict[str, str]] = {} """A mapping of actual item name to logical item name. Useful when there are multiple versions of a collected item but the logic only uses one. For example: @@ -55,6 +61,8 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) self.rule_entrance_dependencies = defaultdict(set) + self.true_rule = self.get_cached_rule(True_.Resolved(player=self.player)) + self.false_rule = self.get_cached_rule(False_.Resolved(player=self.player)) @classmethod def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": @@ -71,17 +79,32 @@ def rule_from_dict(cls, data: Mapping[str, Any]) -> "Rule[Self]": def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": """Returns a resolved rule registered with the caching system for this world""" resolved_rule = rule.resolve(self) - if self.rule_caching_enabled: - for item_name, rule_ids in resolved_rule.item_dependencies().items(): - self.rule_item_dependencies[item_name] |= rule_ids - for region_name, rule_ids in resolved_rule.region_dependencies().items(): - self.rule_region_dependencies[region_name] |= rule_ids - for location_name, rule_ids in resolved_rule.location_dependencies().items(): - self.rule_location_dependencies[location_name] |= rule_ids - for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): - self.rule_entrance_dependencies[entrance_name] |= rule_ids + resolved_rule = self.get_cached_rule(resolved_rule) + return self.simplify_rule(resolved_rule) + + def get_cached_rule(self, resolved_rule: "Rule.Resolved") -> "Rule.Resolved": + if not self.rule_caching_enabled: + # skip the caching logic entirely + object.__setattr__(resolved_rule, "cacheable", False) + object.__setattr__(resolved_rule, "__call__", resolved_rule._evaluate) # pyright: ignore[reportPrivateUsage] + rule_hash = hash(resolved_rule) + if rule_hash in self.rule_ids: + return self.rule_ids[rule_hash] + self.rule_ids[rule_hash] = resolved_rule return resolved_rule + def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: + if not self.rule_caching_enabled: + return + for item_name, rule_ids in resolved_rule.item_dependencies().items(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name, rule_ids in resolved_rule.region_dependencies().items(): + self.rule_region_dependencies[region_name] |= rule_ids + for location_name, rule_ids in resolved_rule.location_dependencies().items(): + self.rule_location_dependencies[location_name] |= rule_ids + for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): + self.rule_entrance_dependencies[entrance_name] |= rule_ids + def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: """Register indirect connections for this entrance based on the rule's dependencies""" for indirect_region in resolved_rule.region_dependencies().keys(): @@ -119,6 +142,7 @@ def register_dependencies(self) -> None: def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: """Resolve and set a rule on a location or entrance""" resolved_rule = self.resolve_rule(rule) + self.register_rule_dependencies(resolved_rule) spot.access_rule = resolved_rule if self.explicit_indirect_conditions and isinstance(spot, Entrance): self.register_rule_connections(resolved_rule, spot) @@ -136,6 +160,7 @@ def create_entrance( resolved_rule = self.resolve_rule(rule) if resolved_rule.always_false: return None + self.register_rule_dependencies(resolved_rule) entrance = from_region.connect(to_region, name) if resolved_rule: @@ -147,6 +172,7 @@ def create_entrance( def set_completion_rule(self, rule: "Rule[Self]") -> None: """Set the completion rule for this world""" resolved_rule = self.resolve_rule(rule) + self.register_rule_dependencies(resolved_rule) self.multiworld.completion_condition[self.player] = resolved_rule self.completion_rule = resolved_rule @@ -165,7 +191,7 @@ def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": true_rule: Rule.Resolved | None = None while children_to_process: - child = children_to_process.pop(0) + child = self.simplify_rule(children_to_process.pop(0)) if child.always_false: # false always wins return child @@ -188,7 +214,7 @@ def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": clauses.append(child) if not clauses and not items: - return true_rule or False_.Resolved(player=rule.player) + return true_rule or self.false_rule has_cls = cast("type[Has[Self]]", self.get_rule_cls("Has")) has_all_cls = cast("type[HasAll[Self]]", self.get_rule_cls("HasAll")) @@ -197,12 +223,12 @@ def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": if count == 1: has_all_items.append(item) else: - clauses.append(has_cls.Resolved(item, count, player=rule.player)) + clauses.append(self.get_cached_rule(has_cls.Resolved(item, count, player=rule.player))) if len(has_all_items) == 1: - clauses.append(has_cls.Resolved(has_all_items[0], player=rule.player)) + clauses.append(self.get_cached_rule(has_cls.Resolved(has_all_items[0], player=rule.player))) elif len(has_all_items) > 1: - clauses.append(has_all_cls.Resolved(tuple(has_all_items), player=rule.player)) + clauses.append(self.get_cached_rule(has_all_cls.Resolved(tuple(has_all_items), player=rule.player))) if len(clauses) == 1: return clauses[0] @@ -218,7 +244,7 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": items: dict[str, int] = {} while children_to_process: - child = children_to_process.pop(0) + child = self.simplify_rule(children_to_process.pop(0)) if child.always_true: # true always wins return child @@ -239,7 +265,7 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": clauses.append(child) if not clauses and not items: - return False_.Resolved(player=rule.player) + return self.false_rule has_cls = cast("type[Has[Self]]", self.get_rule_cls("Has")) has_any_cls = cast("type[HasAny[Self]]", self.get_rule_cls("HasAny")) @@ -248,12 +274,12 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": if count == 1: has_any_items.append(item) else: - clauses.append(has_cls.Resolved(item, count, player=rule.player)) + clauses.append(self.get_cached_rule(has_cls.Resolved(item, count, player=rule.player))) if len(has_any_items) == 1: - clauses.append(has_cls.Resolved(has_any_items[0], player=rule.player)) + clauses.append(self.get_cached_rule(has_cls.Resolved(has_any_items[0], player=rule.player))) elif len(has_any_items) > 1: - clauses.append(has_any_cls.Resolved(tuple(has_any_items), player=rule.player)) + clauses.append(self.get_cached_rule(has_any_cls.Resolved(tuple(has_any_items), player=rule.player))) if len(clauses) == 1: return clauses[0] @@ -474,17 +500,8 @@ def _instantiate(self, world: "TWorld") -> "Resolved": def resolve(self, world: "TWorld") -> "Resolved": """Resolve a rule with the given world""" if not self._passes_options(world.options): - return False_.Resolved(player=world.player) - - instance = self._instantiate(world) - if not world.rule_caching_enabled: - # skip the caching logic entirely - object.__setattr__(instance, "cacheable", False) - object.__setattr__(instance, "__call__", instance._evaluate) # pyright: ignore[reportPrivateUsage] - rule_hash = hash(instance) - if rule_hash not in world.rule_ids: - world.rule_ids[rule_hash] = instance - return world.rule_ids[rule_hash] + return world.false_rule + return self._instantiate(world) def to_dict(self) -> dict[str, Any]: """Returns a JSON compatible dict representation of this rule""" @@ -661,8 +678,8 @@ def __init__(self, *children: "Rule[TWorld]", options: "Iterable[OptionFilter[An @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": - children = [c.resolve(world) for c in self.children] - return world.simplify_rule(self.Resolved(tuple(children), player=world.player)) + children = [world.resolve_rule(c) for c in self.children] + return self.Resolved(tuple(children), player=world.player) @override def to_dict(self) -> dict[str, Any]: @@ -802,7 +819,7 @@ class Wrapper(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": - return self.Resolved(self.child.resolve(world), player=world.player) + return self.Resolved(world.resolve_rule(self.child), player=world.player) @override def to_dict(self) -> dict[str, Any]: @@ -829,7 +846,7 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: - return self.child._evaluate(state) + return self.child(state) @override def item_dependencies(self) -> dict[str, set[int]]: @@ -941,7 +958,7 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: # match state.has_all - return True_().resolve(world) + return world.true_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @@ -1049,7 +1066,7 @@ def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = () def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: # match state.has_any - return False_().resolve(world) + return world.false_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player) @@ -1139,7 +1156,7 @@ def explain_str(self, state: "CollectionState | None" = None) -> str: @override def __str__(self) -> str: items = ", ".join(self.item_names) - return f"Has all of ({items})" + return f"Has any of ({items})" @dataclasses.dataclass() @@ -1153,7 +1170,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"): def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 0: # match state.has_all_counts - return True_().resolve(world) + return world.true_rule if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) @@ -1252,7 +1269,7 @@ class HasAnyCount(HasAllCounts[TWorld], game="Archipelago"): def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_counts) == 0: # match state.has_any_count - return False_().resolve(world) + return world.false_rule if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) @@ -1350,7 +1367,7 @@ def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFi def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0: # match state.has_from_list - return False_().resolve(world) + return world.false_rule if len(self.item_names) == 1: return Has(self.item_names[0], self.count).resolve(world) return self.Resolved(self.item_names, self.count, player=world.player) @@ -1456,7 +1473,7 @@ def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFi def _instantiate(self, world: "TWorld") -> "Rule.Resolved": if len(self.item_names) == 0 or len(self.item_names) < self.count: # match state.has_from_list_unique - return False_().resolve(world) + return world.false_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, self.count, player=world.player) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 19418acfc19b..9b1da35c5c73 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -110,6 +110,38 @@ def get_filler_item_name(self) -> str: Or(HasAll("A"), HasAll("A", "A")), Has.Resolved("A", player=1), ), + ( + Or( + Has("A"), + Or( + True_(options=[OptionFilter(ChoiceOption, 0)]), + HasAny("B", "C", options=[OptionFilter(ChoiceOption, 0, "gt")]), + options=[OptionFilter(ToggleOption, 1)], + ), + And(Has("D"), Has("E"), options=[OptionFilter(ToggleOption, 0)]), + Has("F"), + ), + Or.Resolved( + ( + HasAll.Resolved(("D", "E"), player=1), + HasAny.Resolved(("A", "F"), player=1), + ), + player=1, + ), + ), + ( + Or( + Has("A"), + Or( + True_(options=[OptionFilter(ChoiceOption, 0, "gt")]), + HasAny("B", "C", options=[OptionFilter(ChoiceOption, 0)]), + options=[OptionFilter(ToggleOption, 0)], + ), + And(Has("D"), Has("E"), options=[OptionFilter(ToggleOption, 1)]), + Has("F"), + ), + HasAny.Resolved(("A", "B", "C", "F"), player=1), + ), ) ) class TestSimplify(unittest.TestCase): @@ -120,8 +152,8 @@ def test_simplify(self) -> None: world = multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) rule, expected = self.rules - resolved_rule = rule.resolve(world) - self.assertEqual(resolved_rule, expected, str(resolved_rule)) + resolved_rule = world.resolve_rule(rule) + self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") class TestOptions(unittest.TestCase): @@ -140,22 +172,22 @@ def test_option_filtering(self) -> None: rule = Or(Has("A", options=[OptionFilter(ToggleOption, 0)]), Has("B", options=[OptionFilter(ToggleOption, 1)])) self.world.options.toggle_option.value = 0 - self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) + self.assertEqual(self.world.resolve_rule(rule), Has.Resolved("A", player=1)) self.world.options.toggle_option.value = 1 - self.assertEqual(rule.resolve(self.world), Has.Resolved("B", player=1)) + self.assertEqual(self.world.resolve_rule(rule), Has.Resolved("B", player=1)) def test_gt_filtering(self) -> None: rule = Or(Has("A", options=[OptionFilter(ChoiceOption, 1, operator="gt")]), False_()) self.world.options.choice_option.value = 0 - self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) + self.assertEqual(self.world.resolve_rule(rule), False_.Resolved(player=1)) self.world.options.choice_option.value = 1 - self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) + self.assertEqual(self.world.resolve_rule(rule), False_.Resolved(player=1)) self.world.options.choice_option.value = 2 - self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) + self.assertEqual(self.world.resolve_rule(rule), Has.Resolved("A", player=1)) @classvar_matrix( @@ -225,7 +257,7 @@ def test_has_all_hash(self) -> None: rule1 = HasAll("1", "2") rule2 = HasAll("2", "2", "2", "1") - self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world))) + self.assertEqual(hash(world.resolve_rule(rule1)), hash(world.resolve_rule(rule2))) class TestCaching(unittest.TestCase): @@ -307,6 +339,85 @@ def test_entrance_cache_busting(self) -> None: self.assertTrue(location.can_reach(self.state)) +class TestCacheDisabled(unittest.TestCase): + multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] + world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] + state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + player: int = 1 + + @override + def setUp(self) -> None: + self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + world = self.multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + world.rule_caching_enabled = False # pyright: ignore[reportAttributeAccessIssue] + self.world = world + self.state = self.multiworld.state + + region1 = Region("Region 1", self.player, self.multiworld) + region2 = Region("Region 2", self.player, self.multiworld) + region3 = Region("Region 3", self.player, self.multiworld) + self.multiworld.regions.extend([region1, region2, region3]) + + region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderLocation) + region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) + region3.add_locations({"Location 5": 5}, RuleBuilderLocation) + + world.create_entrance(region1, region2, Has("Item 1")) + world.create_entrance(region1, region3, HasAny("Item 3", "Item 4")) + world.set_rule(world.get_location("Location 2"), CanReachRegion("Region 2") & Has("Item 2")) + world.set_rule(world.get_location("Location 4"), HasAll("Item 2", "Item 3")) + world.set_rule(world.get_location("Location 5"), CanReachLocation("Location 4")) + world.set_rule(world.get_location("Location 6"), CanReachEntrance("Region 1 -> Region 2") & Has("Item 2")) + + for i in range(1, LOC_COUNT + 1): + self.multiworld.itempool.append(world.create_item(f"Item {i}")) + + world.register_dependencies() + + return super().setUp() + + def test_item_logic(self) -> None: + entrance = self.world.get_entrance("Region 1 -> Region 2") + self.assertFalse(entrance.can_reach(self.state)) + self.assertFalse(self.state.rule_cache[1]) + + self.state.collect(self.world.create_item("Item 1")) # item directly needed + self.assertFalse(self.state.rule_cache[1]) + self.assertTrue(entrance.can_reach(self.state)) + + def test_region_logic(self) -> None: + location = self.world.get_location("Location 2") + self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule + self.assertFalse(location.can_reach(self.state)) + self.assertFalse(self.state.rule_cache[1]) + + self.state.collect(self.world.create_item("Item 1")) # item only needed for region 2 access + self.assertTrue(location.can_reach(self.state)) + self.assertFalse(self.state.rule_cache[1]) + + def test_location_logic(self) -> None: + location = self.world.get_location("Location 5") + self.state.collect(self.world.create_item("Item 1")) # access to region 2 + self.state.collect(self.world.create_item("Item 3")) # access to region 3 + self.assertFalse(location.can_reach(self.state)) + self.assertFalse(self.state.rule_cache[1]) + + self.state.collect(self.world.create_item("Item 2")) # item only needed for location 2 access + self.assertFalse(self.state.rule_cache[1]) + self.assertTrue(location.can_reach(self.state)) + + def test_entrance_logic(self) -> None: + location = self.world.get_location("Location 6") + self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule + self.assertFalse(location.can_reach(self.state)) + self.assertFalse(self.state.rule_cache[1]) + + self.state.collect(self.world.create_item("Item 1")) # item only needed for entrance access + self.assertFalse(self.state.rule_cache[1]) + self.assertTrue(location.can_reach(self.state)) + + class TestRules(unittest.TestCase): multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] @@ -324,16 +435,19 @@ def setUp(self) -> None: def test_true(self) -> None: rule = True_() resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertTrue(resolved_rule(self.state)) def test_false(self) -> None: rule = False_() resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) def test_has(self) -> None: rule = Has("Item 1") resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) item = self.world.create_item("Item 1") self.state.collect(item) @@ -344,6 +458,7 @@ def test_has(self) -> None: def test_has_all(self) -> None: rule = HasAll("Item 1", "Item 2") resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) item1 = self.world.create_item("Item 1") self.state.collect(item1) @@ -358,6 +473,7 @@ def test_has_any(self) -> None: item_names = ("Item 1", "Item 2") rule = HasAny(*item_names) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) for item_name in item_names: @@ -370,6 +486,7 @@ def test_has_any(self) -> None: def test_has_all_counts(self) -> None: rule = HasAllCounts({"Item 1": 1, "Item 2": 2}) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) item1 = self.world.create_item("Item 1") self.state.collect(item1) @@ -387,6 +504,7 @@ def test_has_any_count(self) -> None: item_counts = {"Item 1": 1, "Item 2": 2} rule = HasAnyCount(item_counts) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) for item_name, count in item_counts.items(): item = self.world.create_item(item_name) @@ -401,6 +519,7 @@ def test_has_from_list(self) -> None: item_names = ("Item 1", "Item 2", "Item 3") rule = HasFromList(*item_names, count=2) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) items: list[Item] = [] @@ -421,6 +540,7 @@ def test_has_from_list_unique(self) -> None: item_names = ("Item 1", "Item 1", "Item 2") rule = HasFromListUnique(*item_names, count=2) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) items: list[Item] = [] @@ -441,6 +561,7 @@ def test_has_from_list_unique(self) -> None: def test_has_group(self) -> None: rule = HasGroup("Group 1", count=2) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) items: list[Item] = [] for item_name in ("Item 1", "Item 2"): @@ -456,6 +577,7 @@ def test_has_group(self) -> None: def test_has_group_unique(self) -> None: rule = HasGroupUnique("Group 1", count=2) resolved_rule = self.world.resolve_rule(rule) + self.world.register_rule_dependencies(resolved_rule) items: list[Item] = [] for item_name in ("Item 1", "Item 1", "Item 2"): From a877b0b70f4d05450110ac283bb95462cc1e6cbf Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 9 Jul 2025 01:22:35 -0400 Subject: [PATCH 064/135] fix test --- .gitignore | 4 ++++ worlds/astalon/test/test_rules.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5509c5888e29..e3476400b0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,7 @@ tox.toml # UT worlds/tracker + +hooks/ +fuzz_output/ +fuzz.py diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py index e33a6ccd7ac2..b7c1f2da9dbb 100644 --- a/worlds/astalon/test/test_rules.py +++ b/worlds/astalon/test/test_rules.py @@ -73,5 +73,5 @@ def test_upper_path_rule_easy(self) -> None: ), player=self.player, ) - instance = rule.resolve(self.world) + instance = self.world.resolve_rule(rule) self.assertEqual(instance, expected, f"\n{instance}\n{expected}") From 083b9c711be8bc8a66407b5064eac7c405ba681d Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 13 Jul 2025 17:43:26 -0400 Subject: [PATCH 065/135] move filter function to filter class --- rule_builder.py | 44 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index ff477d1d67a1..8c672e40c624 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -343,9 +343,11 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: player_results.pop(rule_id, None) +TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 + Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] -OPERATORS = { +OPERATORS: dict[Operator, Callable[..., bool]] = { "eq": operator.eq, "ne": operator.ne, "gt": operator.gt, @@ -354,7 +356,7 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: "le": operator.le, "contains": operator.contains, } -operator_strings = { +operator_strings: dict[Operator, str] = { "eq": "==", "ne": "!=", "gt": ">", @@ -364,7 +366,6 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: } T = TypeVar("T") -TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 @dataclasses.dataclass(frozen=True) @@ -381,6 +382,20 @@ def to_dict(self) -> dict[str, Any]: "operator": self.operator, } + def check(self, options: "CommonOptions") -> bool: + """Tests the given options dataclass to see if it passes this option filter""" + option_name = next( + (name for name, cls in options.__class__.type_hints.items() if cls is self.option), + None, + ) + if option_name is None: + raise ValueError(f"Cannot find option {self.option.__name__} in options class {options.__class__.__name__}") + opt = cast("Option[Any] | None", getattr(options, option_name, None)) + if opt is None: + raise ValueError(f"Invalid option: {option_name}") + + return OPERATORS[self.operator](opt.value, self.value) + @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: """Returns a new OptionFilter instance from a dict representation""" @@ -475,32 +490,15 @@ def __post_init__(self) -> None: if not isinstance(self.options, tuple): self.options = tuple(self.options) - def _passes_options(self, options: "CommonOptions") -> bool: - """Tests if the given world options pass the requirements for this rule""" - for option_filter in self.options: - option_name = next( - (name for name, cls in options.__class__.type_hints.items() if cls is option_filter.option), - None, - ) - if option_name is None: - raise ValueError(f"Cannot find option: {option_filter.option.__name__}") - opt = cast("Option[Any] | None", getattr(options, option_name, None)) - if opt is None: - raise ValueError(f"Invalid option: {option_name}") - - if not OPERATORS[option_filter.operator](opt.value, option_filter.value): - return False - - return True - def _instantiate(self, world: "TWorld") -> "Resolved": """Create a new resolved rule for this world""" return self.Resolved(player=world.player) def resolve(self, world: "TWorld") -> "Resolved": """Resolve a rule with the given world""" - if not self._passes_options(world.options): - return world.false_rule + for option_filter in self.options: + if not option_filter.check(world.options): + return world.false_rule return self._instantiate(world) def to_dict(self) -> dict[str, Any]: From f9641c1fe2ddd5ab622cfb44db7cf8bc7d0251ec Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 22 Jul 2025 22:06:00 -0400 Subject: [PATCH 066/135] add more docs --- docs/rule builder.md | 63 ++++++++++++++++++++++++++++++++++++++++++++ rule_builder.py | 23 +++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index f6bdac8d969b..deac2966bcb3 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -57,6 +57,8 @@ There is also a `create_entrance` helper that will resolve the rule, check if it 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: ```python @@ -120,6 +122,22 @@ class MyWorld(RuleWorldMixin, World): You'll have to benchmark your own world to see if it should be disabled 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 `Currecy x` items on locations, but your rules only check a singlular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`. + +```python +class MyWorld(RuleWorldMixin, World): + 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 to provide your world as a type argument to add correct type checking to the `_instantiate` method. @@ -361,3 +379,48 @@ class MyRule(Rule, game="My Game"): def __str__(self) -> str: return "You must be THIS tall to beat the game" ``` + +## 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. + +#### Properties + +- `completion_rule: Rule.Resolved | None`: The resolved rule used for the completion condition of this world as set by `set_completion_rule` +- `true_rule: Rule.Resolved`: A pre-resolved rule for this player that is equal to `True_()` +- `false_rule: Rule.Resolved`: A pre-resolved rule for this player that is equal to `False_()` +- `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name +- `rule_caching_enabled: bool`: A boolean value to enable or disable rule caching for this world + +#### Methods + +- `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation +- `register_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies +- `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance +- `create_entrance(from_region: Region, to_rengion: Region, rule: Rule | None, name: str | None = None)`: Attempt to create an entrance from `from_region` to `to_rengion`, skipping creation if `rule` is defined and evaluates to `False_()` +- `set_completion_rule(rule: Rule)`: Sets the completion condition for this world + +### 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 diff --git a/rule_builder.py b/rule_builder.py index 8c672e40c624..d0552f3ccd5e 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -83,6 +83,7 @@ def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": return self.simplify_rule(resolved_rule) def get_cached_rule(self, resolved_rule: "Rule.Resolved") -> "Rule.Resolved": + """Returns a cached instance of a resolved rule based on the hash""" if not self.rule_caching_enabled: # skip the caching logic entirely object.__setattr__(resolved_rule, "cacheable", False) @@ -94,6 +95,7 @@ def get_cached_rule(self, resolved_rule: "Rule.Resolved") -> "Rule.Resolved": return resolved_rule def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: + """Registers a rule's item, region, location, and entrance dependencies to this world instance""" if not self.rule_caching_enabled: return for item_name, rule_ids in resolved_rule.item_dependencies().items(): @@ -111,7 +113,7 @@ def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "E self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) def register_dependencies(self) -> None: - """Register all rules that depend on locations with that location's dependencies""" + """Register all rules that depend on locations or entrances with their dependencies""" if not self.rule_caching_enabled: return @@ -668,7 +670,10 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) class NestedRule(Rule[TWorld], game="Archipelago"): + """A rule that takes an iterable of other rules as an argument and does logic based on them""" + children: "tuple[Rule[TWorld], ...]" + """The child rules this rule's logic is based on""" def __init__(self, *children: "Rule[TWorld]", options: "Iterable[OptionFilter[Any]]" = ()) -> None: super().__init__(options=options) @@ -749,6 +754,8 @@ def entrance_dependencies(self) -> dict[str, set[int]]: @dataclasses.dataclass(init=False) class And(NestedRule[TWorld], game="Archipelago"): + """A rule that only returns true when all child rules evaluate as true""" + class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: @@ -780,6 +787,8 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) class Or(NestedRule[TWorld], game="Archipelago"): + """A rule that returns true when any child rule evaluates as true""" + class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: "CollectionState") -> bool: @@ -814,6 +823,7 @@ class Wrapper(Rule[TWorld], game="Archipelago"): """A rule that wraps another rule to provide extra logic or data""" child: "Rule[TWorld]" + """The child rule being wrapped""" @override def _instantiate(self, world: "TWorld") -> "Rule.Resolved": @@ -892,8 +902,13 @@ def __str__(self) -> str: @dataclasses.dataclass() class Has(Rule[TWorld], game="Archipelago"): + """A rule that checks if the player has at least `count` of a given item""" + item_name: str + """The item to check for""" + count: int = 1 + """The count the player is required to have""" @override def _instantiate(self, world: "TWorld") -> "Resolved": @@ -1580,6 +1595,8 @@ def __str__(self) -> str: @dataclasses.dataclass() class CanReachLocation(Rule[TWorld], game="Archipelago"): + """A rule that checks if the given location is reachable by the current player""" + location_name: str """The name of the location to test access to""" @@ -1651,6 +1668,8 @@ def __str__(self) -> str: @dataclasses.dataclass() class CanReachRegion(Rule[TWorld], game="Archipelago"): + """A rule that checks if the given region is reachable by the current player""" + region_name: str """The name of the region to test access to""" @@ -1701,6 +1720,8 @@ def __str__(self) -> str: @dataclasses.dataclass() class CanReachEntrance(Rule[TWorld], game="Archipelago"): + """A rule that checks if the given entrance is reachable by the current player""" + entrance_name: str """The name of the entrance to test access to""" From 66e3841104da63811c1a2703b9aab61221266702 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 23 Jul 2025 00:02:56 -0400 Subject: [PATCH 067/135] tests for explain functions --- rule_builder.py | 95 ++++++- test/general/test_rule_builder.py | 448 +++++++++++++++++++++++++++++- 2 files changed, 535 insertions(+), 8 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index d0552f3ccd5e..40151b1e0196 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -153,7 +153,7 @@ def create_entrance( self, from_region: "Region", to_region: "Region", - rule: "Rule[Self] | None", + rule: "Rule[Self] | None" = None, name: str | None = None, ) -> "Entrance | None": """Try to create an entrance between regions with the given rule, skipping it if the rule resolves to False""" @@ -939,7 +939,15 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess if self.count > 1: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) messages.append({"type": "text", "text": "x "}) - messages.append({"type": "item_name", "flags": 0b001, "text": self.item_name, "player": self.player}) + item_message: JSONMessagePart = { + "type": "item_name", + "flags": 0b001, + "text": self.item_name, + "player": self.player, + } + if state: + item_message["color"] = "green" if self(state) else "salmon" + messages.append(item_message) return messages @override @@ -1418,7 +1426,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess messages = [ {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": str(self.count)}, - {"type": "text", "text": " items from ("}, + {"type": "text", "text": "x items from ("}, ] for i, item in enumerate(self.item_names): if i > 0: @@ -1427,11 +1435,16 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess messages.append({"type": "text", "text": ")"}) return messages + found_count = state.count_from_list(self.item_names, self.player) found = [item for item in self.item_names if state.has(item, self.player)] missing = [item for item in self.item_names if item not in found] messages = [ {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": f"{len(found)}/{self.count}"}, + { + "type": "color", + "color": "green" if found_count >= self.count else "salmon", + "text": f"{found_count}/{self.count}", + }, {"type": "text", "text": " items from ("}, ] if found: @@ -1460,17 +1473,19 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess def explain_str(self, state: "CollectionState | None" = None) -> str: if state is None: return str(self) + found_count = state.count_from_list(self.item_names, self.player) found = [item for item in self.item_names if state.has(item, self.player)] missing = [item for item in self.item_names if item not in found] found_str = f"Found: {', '.join(found)}" if found else "" missing_str = f"Missing: {', '.join(missing)}" if missing else "" infix = "; " if found and missing else "" - return f"Has {len(found)}/{self.count} items from ({found_str}{infix}{missing_str})" + return f"Has {found_count}/{self.count} items from ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: items = ", ".join(self.item_names) - return f"Has {self.count} items from ({items})" + count = f"{self.count}x items" if self.count > 1 else "an item" + return f"Has {count} from ({items})" @dataclasses.dataclass(init=False) @@ -1496,6 +1511,74 @@ class Resolved(HasFromList.Resolved): def _evaluate(self, state: "CollectionState") -> bool: return state.has_from_list_unique(self.item_names, self.player, self.count) + @override + def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + messages: list[JSONMessagePart] = [] + if state is None: + messages = [ + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": str(self.count)}, + {"type": "text", "text": "x unique items from ("}, + ] + for i, item in enumerate(self.item_names): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append({"type": "item_name", "flags": 0b001, "text": item, "player": self.player}) + messages.append({"type": "text", "text": ")"}) + return messages + + found_count = state.count_from_list_unique(self.item_names, self.player) + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + messages = [ + {"type": "text", "text": "Has "}, + { + "type": "color", + "color": "green" if found_count >= self.count else "salmon", + "text": f"{found_count}/{self.count}", + }, + {"type": "text", "text": " unique items from ("}, + ] + if found: + messages.append({"type": "text", "text": "Found: "}) + for i, item in enumerate(found): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} + ) + if missing: + messages.append({"type": "text", "text": "; "}) + + if missing: + messages.append({"type": "text", "text": "Missing: "}) + for i, item in enumerate(missing): + if i > 0: + messages.append({"type": "text", "text": ", "}) + messages.append( + {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} + ) + messages.append({"type": "text", "text": ")"}) + return messages + + @override + def explain_str(self, state: "CollectionState | None" = None) -> str: + if state is None: + return str(self) + found_count = state.count_from_list_unique(self.item_names, self.player) + found = [item for item in self.item_names if state.has(item, self.player)] + missing = [item for item in self.item_names if item not in found] + found_str = f"Found: {', '.join(found)}" if found else "" + missing_str = f"Missing: {', '.join(missing)}" if missing else "" + infix = "; " if found and missing else "" + return f"Has {found_count}/{self.count} unique items from ({found_str}{infix}{missing_str})" + + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + count = f"{self.count}x unique items" if self.count > 1 else "a unique item" + return f"Has {count} from ({items})" + @dataclasses.dataclass() class HasGroup(Rule[TWorld], game="Archipelago"): diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 9b1da35c5c73..c9a9428400ac 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from BaseClasses import CollectionState, MultiWorld + from NetUtils import JSONMessagePart class ToggleOption(Toggle): @@ -54,7 +55,7 @@ class RuleBuilderOptions(PerGameCommonOptions): GAME = "Rule Builder Test Game" -LOC_COUNT = 6 +LOC_COUNT = 20 class RuleBuilderItem(Item): @@ -69,7 +70,10 @@ class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMu game: ClassVar[str] = GAME item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} - item_name_groups: ClassVar[dict[str, set[str]]] = {"Group 1": {"Item 1", "Item 2", "Item 3"}} + item_name_groups: ClassVar[dict[str, set[str]]] = { + "Group 1": {"Item 1", "Item 2", "Item 3"}, + "Group 2": {"Item 4", "Item 5"}, + } hidden: ClassVar[bool] = True options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] @@ -142,6 +146,22 @@ def get_filler_item_name(self) -> str: ), HasAny.Resolved(("A", "B", "C", "F"), player=1), ), + ( + And(Has("A"), True_()), + Has.Resolved("A", player=1), + ), + ( + And(Has("A"), False_()), + False_.Resolved(player=1), + ), + ( + Or(Has("A"), True_()), + True_.Resolved(player=1), + ), + ( + Or(Has("A"), False_()), + Has.Resolved("A", player=1), + ), ) ) class TestSimplify(unittest.TestCase): @@ -592,6 +612,13 @@ def test_has_group_unique(self) -> None: self.state.remove(items[1]) self.assertFalse(resolved_rule(self.state)) + def test_completion_rule(self) -> None: + rule = Has("Item 1") + self.world.set_completion_rule(rule) + self.assertEqual(self.multiworld.can_beat_game(self.state), False) + self.state.collect(self.world.create_item("Item 1")) + self.assertEqual(self.multiworld.can_beat_game(self.state), True) + class TestSerialization(unittest.TestCase): maxDiff: int | None = None @@ -741,3 +768,420 @@ def test_deserialize(self) -> None: deserialized_rule = world.rule_from_dict(self.rule_dict) self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule)) + + +class TestExplain(unittest.TestCase): + multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] + world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] + state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + player: int = 1 + + resolved_rule: ClassVar[Rule.Resolved] = And.Resolved( + ( + Or.Resolved( + ( + Has.Resolved("Item 1", count=4, player=1), + HasAll.Resolved(("Item 2", "Item 3"), player=1), + HasAny.Resolved(("Item 4", "Item 5"), player=1), + ), + player=1, + ), + HasAllCounts.Resolved((("Item 6", 1), ("Item 7", 5)), player=1), + HasAnyCount.Resolved((("Item 8", 2), ("Item 9", 3)), player=1), + HasFromList.Resolved(("Item 10", "Item 11", "Item 12"), count=2, player=1), + HasFromListUnique.Resolved(("Item 13", "Item 14"), player=1), + HasGroup.Resolved("Group 1", ("Item 15", "Item 16", "Item 17"), player=1), + HasGroupUnique.Resolved("Group 2", ("Item 18", "Item 19"), count=2, player=1), + CanReachRegion.Resolved("Region 2", player=1), + CanReachLocation.Resolved("Location 2", "Region 2", player=1), + CanReachEntrance.Resolved("Entrance 2", "Region 2", player=1), + True_.Resolved(player=1), + False_.Resolved(player=1), + ), + player=1, + ) + + @override + def setUp(self) -> None: + self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + world = self.multiworld.worlds[1] + assert isinstance(world, RuleBuilderWorld) + self.world = world + self.state = self.multiworld.state + + region1 = Region("Region 1", self.player, self.multiworld) + region2 = Region("Region 2", self.player, self.multiworld) + region3 = Region("Region 3", self.player, self.multiworld) + self.multiworld.regions.extend([region1, region2, region3]) + + region2.add_locations({"Location 2": 1}, RuleBuilderLocation) + world.create_entrance(region1, region2, Has("Item 1")) + world.create_entrance(region2, region3, name="Entrance 2") + + def _collect_all(self) -> None: + for i in range(1, LOC_COUNT + 1): + for _ in range(10): + item = self.world.create_item(f"Item {i}") + self.state.collect(item) + + def test_explain_json_with_state_no_items(self) -> None: + expected: list[JSONMessagePart] = [ + {"type": "text", "text": "("}, + {"type": "text", "text": "("}, + {"type": "text", "text": "Missing "}, + {"type": "color", "color": "cyan", "text": "4"}, + {"type": "text", "text": "x "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 1", "player": 1}, + {"type": "text", "text": " | "}, + {"type": "text", "text": "Missing "}, + {"type": "color", "color": "cyan", "text": "some"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Missing: "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 2", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 3", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " | "}, + {"type": "text", "text": "Missing "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Missing: "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 4", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 5", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Missing "}, + {"type": "color", "color": "cyan", "text": "some"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Missing: "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 6", "player": 1}, + {"type": "text", "text": " x1"}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 7", "player": 1}, + {"type": "text", "text": " x5"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Missing "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Missing: "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 8", "player": 1}, + {"type": "text", "text": " x2"}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 9", "player": 1}, + {"type": "text", "text": " x3"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "salmon", "text": "0/2"}, + {"type": "text", "text": " items from ("}, + {"type": "text", "text": "Missing: "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 10", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 11", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 12", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "salmon", "text": "0/1"}, + {"type": "text", "text": " unique items from ("}, + {"type": "text", "text": "Missing: "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 13", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 14", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "salmon", "text": "0/1"}, + {"type": "text", "text": " items from "}, + {"type": "color", "color": "cyan", "text": "Group 1"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "salmon", "text": "0/2"}, + {"type": "text", "text": " unique items from "}, + {"type": "color", "color": "cyan", "text": "Group 2"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Cannot reach region "}, + {"type": "color", "color": "yellow", "text": "Region 2"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Cannot reach location "}, + {"type": "location_name", "text": "Location 2", "player": 1}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Cannot reach entrance "}, + {"type": "entrance_name", "text": "Entrance 2", "player": 1}, + {"type": "text", "text": " & "}, + {"type": "color", "color": "green", "text": "True"}, + {"type": "text", "text": " & "}, + {"type": "color", "color": "salmon", "text": "False"}, + {"type": "text", "text": ")"}, + ] + assert self.resolved_rule.explain_json(self.state) == expected + + def test_explain_json_with_state_all_items(self) -> None: + self._collect_all() + + expected: list[JSONMessagePart] = [ + {"type": "text", "text": "("}, + {"type": "text", "text": "("}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "4"}, + {"type": "text", "text": "x "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 1", "player": 1}, + {"type": "text", "text": " | "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Found: "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 2", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 3", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " | "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "some"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Found: "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 4", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 5", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Found: "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 6", "player": 1}, + {"type": "text", "text": " x1"}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 7", "player": 1}, + {"type": "text", "text": " x5"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "some"}, + {"type": "text", "text": " of ("}, + {"type": "text", "text": "Found: "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 8", "player": 1}, + {"type": "text", "text": " x2"}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 9", "player": 1}, + {"type": "text", "text": " x3"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "green", "text": "30/2"}, + {"type": "text", "text": " items from ("}, + {"type": "text", "text": "Found: "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 10", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 11", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 12", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "green", "text": "2/1"}, + {"type": "text", "text": " unique items from ("}, + {"type": "text", "text": "Found: "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 13", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "color": "green", "text": "Item 14", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "green", "text": "30/1"}, + {"type": "text", "text": " items from "}, + {"type": "color", "color": "cyan", "text": "Group 1"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "green", "text": "2/2"}, + {"type": "text", "text": " unique items from "}, + {"type": "color", "color": "cyan", "text": "Group 2"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Reached region "}, + {"type": "color", "color": "yellow", "text": "Region 2"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Reached location "}, + {"type": "location_name", "text": "Location 2", "player": 1}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Reached entrance "}, + {"type": "entrance_name", "text": "Entrance 2", "player": 1}, + {"type": "text", "text": " & "}, + {"type": "color", "color": "green", "text": "True"}, + {"type": "text", "text": " & "}, + {"type": "color", "color": "salmon", "text": "False"}, + {"type": "text", "text": ")"}, + ] + assert self.resolved_rule.explain_json(self.state) == expected + + def test_explain_json_without_state(self) -> None: + expected: list[JSONMessagePart] = [ + {"type": "text", "text": "("}, + {"type": "text", "text": "("}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "4"}, + {"type": "text", "text": "x "}, + {"type": "item_name", "flags": 1, "text": "Item 1", "player": 1}, + {"type": "text", "text": " | "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + {"type": "item_name", "flags": 1, "text": "Item 2", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 3", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " | "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "any"}, + {"type": "text", "text": " of ("}, + {"type": "item_name", "flags": 1, "text": "Item 4", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 5", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "all"}, + {"type": "text", "text": " of ("}, + {"type": "item_name", "flags": 1, "text": "Item 6", "player": 1}, + {"type": "text", "text": " x1"}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 7", "player": 1}, + {"type": "text", "text": " x5"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "any"}, + {"type": "text", "text": " of ("}, + {"type": "item_name", "flags": 1, "text": "Item 8", "player": 1}, + {"type": "text", "text": " x2"}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 9", "player": 1}, + {"type": "text", "text": " x3"}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "2"}, + {"type": "text", "text": "x items from ("}, + {"type": "item_name", "flags": 1, "text": "Item 10", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 11", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 12", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "1"}, + {"type": "text", "text": "x unique items from ("}, + {"type": "item_name", "flags": 1, "text": "Item 13", "player": 1}, + {"type": "text", "text": ", "}, + {"type": "item_name", "flags": 1, "text": "Item 14", "player": 1}, + {"type": "text", "text": ")"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "1"}, + {"type": "text", "text": " items from "}, + {"type": "color", "color": "cyan", "text": "Group 1"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Has "}, + {"type": "color", "color": "cyan", "text": "2"}, + {"type": "text", "text": " unique items from "}, + {"type": "color", "color": "cyan", "text": "Group 2"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Can reach region "}, + {"type": "color", "color": "yellow", "text": "Region 2"}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Can reach location "}, + {"type": "location_name", "text": "Location 2", "player": 1}, + {"type": "text", "text": " & "}, + {"type": "text", "text": "Can reach entrance "}, + {"type": "entrance_name", "text": "Entrance 2", "player": 1}, + {"type": "text", "text": " & "}, + {"type": "color", "color": "green", "text": "True"}, + {"type": "text", "text": " & "}, + {"type": "color", "color": "salmon", "text": "False"}, + {"type": "text", "text": ")"}, + ] + assert self.resolved_rule.explain_json() == expected + + def test_explain_str_with_state_no_items(self) -> None: + expected = ( + "((Missing 4x Item 1", + "| Missing some of (Missing: Item 2, Item 3)", + "| Missing all of (Missing: Item 4, Item 5))", + "& Missing some of (Missing: Item 6 x1, Item 7 x5)", + "& Missing all of (Missing: Item 8 x2, Item 9 x3)", + "& Has 0/2 items from (Missing: Item 10, Item 11, Item 12)", + "& Has 0/1 unique items from (Missing: Item 13, Item 14)", + "& Has 0/1 items from Group 1", + "& Has 0/2 unique items from Group 2", + "& Cannot reach region Region 2", + "& Cannot reach location Location 2", + "& Cannot reach entrance Entrance 2", + "& True", + "& False)", + ) + assert self.resolved_rule.explain_str(self.state) == " ".join(expected) + + def test_explain_str_with_state_all_items(self) -> None: + self._collect_all() + + expected = ( + "((Has 4x Item 1", + "| Has all of (Found: Item 2, Item 3)", + "| Has some of (Found: Item 4, Item 5))", + "& Has all of (Found: Item 6 x1, Item 7 x5)", + "& Has some of (Found: Item 8 x2, Item 9 x3)", + "& Has 30/2 items from (Found: Item 10, Item 11, Item 12)", + "& Has 2/1 unique items from (Found: Item 13, Item 14)", + "& Has 30/1 items from Group 1", + "& Has 2/2 unique items from Group 2", + "& Reached region Region 2", + "& Reached location Location 2", + "& Reached entrance Entrance 2", + "& True", + "& False)", + ) + assert self.resolved_rule.explain_str(self.state) == " ".join(expected) + + def test_explain_str_without_state(self) -> None: + expected = ( + "((Has 4x Item 1", + "| Has all of (Item 2, Item 3)", + "| Has any of (Item 4, Item 5))", + "& Has all of (Item 6 x1, Item 7 x5)", + "& Has any of (Item 8 x2, Item 9 x3)", + "& Has 2x items from (Item 10, Item 11, Item 12)", + "& Has a unique item from (Item 13, Item 14)", + "& Has an item from Group 1", + "& Has 2x unique items from Group 2", + "& Can reach region Region 2", + "& Can reach location Location 2", + "& Can reach entrance Entrance 2", + "& True", + "& False)", + ) + assert self.resolved_rule.explain_str() == " ".join(expected) + + def test_str(self) -> None: + expected = ( + "((Has 4x Item 1", + "| Has all of (Item 2, Item 3)", + "| Has any of (Item 4, Item 5))", + "& Has all of (Item 6 x1, Item 7 x5)", + "& Has any of (Item 8 x2, Item 9 x3)", + "& Has 2x items from (Item 10, Item 11, Item 12)", + "& Has a unique item from (Item 13, Item 14)", + "& Has an item from Group 1", + "& Has 2x unique items from Group 2", + "& Can reach region Region 2", + "& Can reach location Location 2", + "& Can reach entrance Entrance 2", + "& True", + "& False)", + ) + assert str(self.resolved_rule) == " ".join(expected) From 56432bfb5b3b75413ba1b8d82505da5f9c0b41ed Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 1 Aug 2025 21:25:46 -0400 Subject: [PATCH 068/135] Update docs/rule builder.md Co-authored-by: roseasromeo <11944660+roseasromeo@users.noreply.github.com> --- docs/rule builder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index deac2966bcb3..10f44ce711a0 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -401,7 +401,7 @@ These are properties and helpers that are available to you in your world. - `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation - `register_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies - `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance -- `create_entrance(from_region: Region, to_rengion: Region, rule: Rule | None, name: str | None = None)`: Attempt to create an entrance from `from_region` to `to_rengion`, skipping creation if `rule` is defined and evaluates to `False_()` +- `create_entrance(from_region: Region, to_rengion: Region, rule: Rule | None, name: str | None = None)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` - `set_completion_rule(rule: Rule)`: Sets the completion condition for this world ### Rule API From 19b858e317bec96ddbeacaa1e4c4375dbd1f21ab Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 1 Aug 2025 21:43:46 -0400 Subject: [PATCH 069/135] chore: Strip out uses of TYPE_CHECKING as much as possible --- rule_builder.py | 242 +++++++++++++++--------------- test/general/test_rule_builder.py | 37 +++-- 2 files changed, 137 insertions(+), 142 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 40151b1e0196..85800d91fe55 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -2,18 +2,16 @@ import importlib import operator from collections import defaultdict -from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast +from collections.abc import Callable, Iterable, Mapping +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast -from typing_extensions import ClassVar, Never, Self, TypeVar, dataclass_transform, override +from typing_extensions import Never, Self, TypeVar, dataclass_transform, override -from BaseClasses import Entrance -from Options import Option +from BaseClasses import CollectionState, Entrance, Item, Location, MultiWorld, Region +from NetUtils import JSONMessagePart +from Options import CommonOptions, Option if TYPE_CHECKING: - from BaseClasses import CollectionState, Item, Location, MultiWorld, Region - from NetUtils import JSONMessagePart - from Options import CommonOptions from worlds.AutoWorld import World else: World = object @@ -22,7 +20,7 @@ class RuleWorldMixin(World): """A World mixin that provides helpers for interacting with the rule builder""" - rule_ids: "dict[int, Rule.Resolved]" + rule_ids: dict[int, "Rule.Resolved"] """A mapping of ids to resolved rules""" rule_item_dependencies: dict[str, set[int]] @@ -54,7 +52,7 @@ class RuleWorldMixin(World): rule_caching_enabled: ClassVar[bool] = True """Enable or disable the rule result caching system""" - def __init__(self, multiworld: "MultiWorld", player: int) -> None: + def __init__(self, multiworld: MultiWorld, player: int) -> None: super().__init__(multiworld, player) self.rule_ids = {} self.rule_item_dependencies = defaultdict(set) @@ -65,7 +63,7 @@ def __init__(self, multiworld: "MultiWorld", player: int) -> None: self.false_rule = self.get_cached_rule(False_.Resolved(player=self.player)) @classmethod - def get_rule_cls(cls, name: str) -> "type[Rule[Self]]": + 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) @@ -107,7 +105,7 @@ def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): self.rule_entrance_dependencies[entrance_name] |= rule_ids - def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: "Entrance") -> None: + def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: Entrance) -> None: """Register indirect connections for this entrance based on the rule's dependencies""" for indirect_region in resolved_rule.region_dependencies().keys(): self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) @@ -141,7 +139,7 @@ def register_dependencies(self) -> None: for region_name in entrance.access_rule.region_dependencies(): self.rule_region_dependencies[region_name] |= rule_ids - def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: + def set_rule(self, spot: Location | Entrance, rule: "Rule[Self]") -> None: """Resolve and set a rule on a location or entrance""" resolved_rule = self.resolve_rule(rule) self.register_rule_dependencies(resolved_rule) @@ -151,11 +149,11 @@ def set_rule(self, spot: "Location | Entrance", rule: "Rule[Self]") -> None: def create_entrance( self, - from_region: "Region", - to_region: "Region", + from_region: Region, + to_region: Region, rule: "Rule[Self] | None" = None, name: str | None = None, - ) -> "Entrance | None": + ) -> Entrance | None: """Try to create an entrance between regions with the given rule, skipping it if the rule resolves to False""" resolved_rule = None if rule is not None: @@ -218,8 +216,8 @@ def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": if not clauses and not items: return true_rule or self.false_rule - has_cls = cast("type[Has[Self]]", self.get_rule_cls("Has")) - has_all_cls = cast("type[HasAll[Self]]", self.get_rule_cls("HasAll")) + has_cls = cast(type[Has[Self]], self.get_rule_cls("Has")) + has_all_cls = cast(type[HasAll[Self]], self.get_rule_cls("HasAll")) has_all_items: list[str] = [] for item, count in items.items(): if count == 1: @@ -269,8 +267,8 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": if not clauses and not items: return self.false_rule - has_cls = cast("type[Has[Self]]", self.get_rule_cls("Has")) - has_any_cls = cast("type[HasAny[Self]]", self.get_rule_cls("HasAny")) + has_cls = cast(type[Has[Self]], self.get_rule_cls("Has")) + has_any_cls = cast(type[HasAny[Self]], self.get_rule_cls("HasAny")) has_any_items: list[str] = [] for item, count in items.items(): if count == 1: @@ -292,7 +290,7 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": ) @override - def collect(self, state: "CollectionState", item: "Item") -> bool: + def collect(self, state: CollectionState, item: Item) -> bool: changed = super().collect(state, item) if changed and self.rule_caching_enabled and getattr(self, "rule_item_dependencies", None): player_results = state.rule_cache[self.player] @@ -304,7 +302,7 @@ def collect(self, state: "CollectionState", item: "Item") -> bool: return changed @override - def remove(self, state: "CollectionState", item: "Item") -> bool: + def remove(self, state: CollectionState, item: Item) -> bool: changed = super().remove(state, item) if not changed or not self.rule_caching_enabled: return changed @@ -337,7 +335,7 @@ def remove(self, state: "CollectionState", item: "Item") -> bool: return changed @override - def reached_region(self, state: "CollectionState", region: "Region") -> None: + def reached_region(self, state: CollectionState, region: Region) -> None: super().reached_region(state, region) if self.rule_caching_enabled and getattr(self, "rule_region_dependencies", None): player_results = state.rule_cache[self.player] @@ -372,7 +370,7 @@ def reached_region(self, state: "CollectionState", region: "Region") -> None: @dataclasses.dataclass(frozen=True) class OptionFilter(Generic[T]): - option: "type[Option[T]]" + option: type[Option[T]] value: T operator: Operator = "eq" @@ -384,7 +382,7 @@ def to_dict(self) -> dict[str, Any]: "operator": self.operator, } - def check(self, options: "CommonOptions") -> bool: + def check(self, options: CommonOptions) -> bool: """Tests the given options dataclass to see if it passes this option filter""" option_name = next( (name for name, cls in options.__class__.type_hints.items() if cls is self.option), @@ -392,7 +390,7 @@ def check(self, options: "CommonOptions") -> bool: ) if option_name is None: raise ValueError(f"Cannot find option {self.option.__name__} in options class {options.__class__.__name__}") - opt = cast("Option[Any] | None", getattr(options, option_name, None)) + opt = cast(Option[Any] | None, getattr(options, option_name, None)) if opt is None: raise ValueError(f"Invalid option: {option_name}") @@ -416,10 +414,10 @@ def from_dict(cls, data: dict[str, Any]) -> Self: value = data["value"] operator = data.get("operator", "eq") - return cls(option=cast("type[Option[Any]]", option), value=value, operator=operator) + return cls(option=cast(type[Option[Any]], option), value=value, operator=operator) @classmethod - def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> "tuple[OptionFilter[Any], ...]": + def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple["OptionFilter[Any]", ...]: """Returns a tuple of OptionFilters instances from an iterable of dict representations""" return tuple(cls.from_dict(o) for o in data) @@ -429,8 +427,8 @@ def __str__(self) -> str: return f"{self.option.__name__} {op} {self.value}" -def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> "Callable[..., int]": - def __hash__(self: "Rule.Resolved") -> int: +def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> Callable[..., int]: + def hash_impl(self: "Rule.Resolved") -> int: return hash( ( self.__class__.__module__, @@ -439,8 +437,8 @@ def __hash__(self: "Rule.Resolved") -> int: ) ) - __hash__.__qualname__ = f"{resolved_rule_cls.__qualname__}.{__hash__.__name__}" - return __hash__ + hash_impl.__qualname__ = f"{resolved_rule_cls.__qualname__}.__hash__" + return hash_impl @dataclass_transform(frozen_default=True, field_specifiers=(dataclasses.field, dataclasses.Field)) @@ -460,7 +458,7 @@ def __new__( namespace: dict[str, Any], /, **kwds: dict[str, Any], - ) -> "type[CustomRuleRegister]": + ) -> type["CustomRuleRegister"]: new_cls = super().__new__(cls, name, bases, namespace, **kwds) new_cls.__hash__ = _create_hash_fn(new_cls) rule_name = new_cls.__qualname__ @@ -470,7 +468,7 @@ def __new__( return dataclasses.dataclass(frozen=True)(new_cls) @classmethod - def get_rule_cls(cls, game_name: str, rule_name: str) -> "type[Rule[Any]]": + def get_rule_cls(cls, game_name: str, rule_name: str) -> type["Rule[Any]"]: """Returns the world-registered or default rule with the given name""" custom_rule_classes = cls.custom_rules.get(game_name, {}) if rule_name not in DEFAULT_RULES and rule_name not in custom_rule_classes: @@ -482,7 +480,7 @@ def get_rule_cls(cls, game_name: str, rule_name: str) -> "type[Rule[Any]]": class Rule(Generic[TWorld]): """Base class for a static rule used to generate an access rule""" - options: "Iterable[OptionFilter[Any]]" = dataclasses.field(default=(), kw_only=True) + options: Iterable[OptionFilter[Any]] = dataclasses.field(default=(), kw_only=True) """An iterable of OptionFilters to restrict what options are required for this rule to be active""" game_name: ClassVar[str] @@ -492,11 +490,11 @@ def __post_init__(self) -> None: if not isinstance(self.options, tuple): self.options = tuple(self.options) - def _instantiate(self, world: "TWorld") -> "Resolved": + def _instantiate(self, world: TWorld) -> "Resolved": """Create a new resolved rule for this world""" return self.Resolved(player=world.player) - def resolve(self, world: "TWorld") -> "Resolved": + def resolve(self, world: TWorld) -> "Resolved": """Resolve a rule with the given world""" for option_filter in self.options: if not option_filter.check(world.options): @@ -515,7 +513,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: """Returns a new instance of this rule from a serialized dict representation""" options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(**data.get("args", {}), options=options) @@ -582,7 +580,7 @@ class Resolved(metaclass=CustomRuleRegister): always_false: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" - def __call__(self, state: "CollectionState") -> bool: + def __call__(self, state: CollectionState) -> bool: """Evaluate this rule's result with the given state, using the cached value if possible""" cached_result = None if self.cacheable: @@ -594,7 +592,7 @@ def __call__(self, state: "CollectionState") -> bool: state.rule_cache[self.player][id(self)] = result return result - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: """Calculate this rule's result with the given state""" ... @@ -615,11 +613,11 @@ def entrance_dependencies(self) -> dict[str, set[int]]: """Returns a mapping of entrance name to set of object ids, used for cache invalidation""" return {} - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: """Returns a list of printJSON messages that explain the logic for this rule""" return [{"type": "text", "text": self.rule_name}] - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: """Returns a human readable string describing this rule""" return str(self) @@ -629,18 +627,18 @@ def __str__(self) -> str: @dataclasses.dataclass() -class True_(Rule[TWorld], game="Archipelago"): +class True_(Rule[TWorld], game="Archipelago"): # noqa: N801 """A rule that always returns True""" class Resolved(Rule.Resolved): always_true: ClassVar[bool] = True @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return True @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: return [{"type": "color", "color": "green", "text": "True"}] @override @@ -649,18 +647,18 @@ def __str__(self) -> str: @dataclasses.dataclass() -class False_(Rule[TWorld], game="Archipelago"): +class False_(Rule[TWorld], game="Archipelago"): # noqa: N801 """A rule that always returns False""" class Resolved(Rule.Resolved): always_false: ClassVar[bool] = True @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return False @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: return [{"type": "color", "color": "salmon", "text": "False"}] @override @@ -672,15 +670,15 @@ def __str__(self) -> str: class NestedRule(Rule[TWorld], game="Archipelago"): """A rule that takes an iterable of other rules as an argument and does logic based on them""" - children: "tuple[Rule[TWorld], ...]" + children: tuple[Rule[TWorld], ...] """The child rules this rule's logic is based on""" - def __init__(self, *children: "Rule[TWorld]", options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *children: Rule[TWorld], options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__(options=options) self.children = children @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: children = [world.resolve_rule(c) for c in self.children] return self.Resolved(tuple(children), player=world.player) @@ -693,7 +691,7 @@ def to_dict(self) -> dict[str, Any]: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: children = [world_cls.rule_from_dict(c) for c in data.get("children", ())] options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*children, options=options) @@ -705,7 +703,7 @@ def __str__(self) -> str: return f"{self.__class__.__name__}({children}{options})" class Resolved(Rule.Resolved): - children: "tuple[Rule.Resolved, ...]" + children: tuple[Rule.Resolved, ...] @override def item_dependencies(self) -> dict[str, set[int]]: @@ -758,14 +756,14 @@ class And(NestedRule[TWorld], game="Archipelago"): class Resolved(NestedRule.Resolved): @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: for rule in self.children: if not rule(state): return False return True @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] for i, child in enumerate(self.children): if i > 0: @@ -775,7 +773,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: clauses = " & ".join([c.explain_str(state) for c in self.children]) return f"({clauses})" @@ -791,14 +789,14 @@ class Or(NestedRule[TWorld], game="Archipelago"): class Resolved(NestedRule.Resolved): @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: for rule in self.children: if rule(state): return True return False @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [{"type": "text", "text": "("}] for i, child in enumerate(self.children): if i > 0: @@ -808,7 +806,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: clauses = " | ".join([c.explain_str(state) for c in self.children]) return f"({clauses})" @@ -822,11 +820,11 @@ def __str__(self) -> str: class Wrapper(Rule[TWorld], game="Archipelago"): """A rule that wraps another rule to provide extra logic or data""" - child: "Rule[TWorld]" + child: Rule[TWorld] """The child rule being wrapped""" @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved(world.resolve_rule(self.child), player=world.player) @override @@ -838,7 +836,7 @@ def to_dict(self) -> dict[str, Any]: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: child = data.get("child") if child is None: raise ValueError("Child rule cannot be None") @@ -850,10 +848,10 @@ def __str__(self) -> str: return f"{self.__class__.__name__}[{self.child}]" class Resolved(Rule.Resolved): - child: "Rule.Resolved" + child: Rule.Resolved @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return self.child(state) @override @@ -885,14 +883,14 @@ def entrance_dependencies(self) -> dict[str, set[int]]: return deps @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: "list[JSONMessagePart]" = [{"type": "text", "text": f"{self.rule_name} ["}] + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: + messages: list[JSONMessagePart] = [{"type": "text", "text": f"{self.rule_name} ["}] messages.extend(self.child.explain_json(state)) messages.append({"type": "text", "text": "]"}) return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: return f"{self.rule_name}[{self.child.explain_str(state)}]" @override @@ -911,7 +909,7 @@ class Has(Rule[TWorld], game="Archipelago"): """The count the player is required to have""" @override - def _instantiate(self, world: "TWorld") -> "Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved(self.item_name, self.count, player=world.player) @override @@ -925,7 +923,7 @@ class Resolved(Rule.Resolved): count: int = 1 @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has(self.item_name, self.player, count=self.count) @override @@ -933,7 +931,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {self.item_name: {id(self)}} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: verb = "Missing " if state and not self(state) else "Has " messages: list[JSONMessagePart] = [{"type": "text", "text": verb}] if self.count > 1: @@ -951,7 +949,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) prefix = "Has" if self(state) else "Missing" @@ -971,12 +969,12 @@ class HasAll(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *item_names: str, options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_all return world.true_rule @@ -986,7 +984,7 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1002,7 +1000,7 @@ class Resolved(Rule.Resolved): item_names: tuple[str, ...] @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has_all(self.item_names, self.player) @override @@ -1010,7 +1008,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] if state is None: messages = [ @@ -1055,7 +1053,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) found = [item for item in self.item_names if state.has(item, self.player)] @@ -1079,12 +1077,12 @@ class HasAny(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - def __init__(self, *item_names: str, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *item_names: str, options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_any return world.false_rule @@ -1094,7 +1092,7 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1110,7 +1108,7 @@ class Resolved(Rule.Resolved): item_names: tuple[str, ...] @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has_any(self.item_names, self.player) @override @@ -1118,7 +1116,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] if state is None: messages = [ @@ -1163,7 +1161,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) found = [item for item in self.item_names if state.has(item, self.player)] @@ -1188,7 +1186,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"): """A mapping of item name to count to check for""" @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 0: # match state.has_all_counts return world.true_rule @@ -1207,7 +1205,7 @@ class Resolved(Rule.Resolved): item_counts: tuple[tuple[str, int], ...] @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: # it will certainly be faster to reimplement has_all_counts here # I'm leaving it for now so I can benchmark it later return state.has_all_counts(dict(self.item_counts), self.player) @@ -1217,7 +1215,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item, _ in self.item_counts} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] if state is None: messages = [ @@ -1265,7 +1263,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] @@ -1287,7 +1285,7 @@ class HasAnyCount(HasAllCounts[TWorld], game="Archipelago"): """A rule that checks if the player has any of the specified counts of the given items""" @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 0: # match state.has_any_count return world.false_rule @@ -1298,13 +1296,13 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": class Resolved(HasAllCounts.Resolved): @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: # it will certainly be faster to reimplement has_all_counts here # I'm leaving it for now so I can benchmark it later return state.has_any_count(dict(self.item_counts), self.player) @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] if state is None: messages = [ @@ -1352,7 +1350,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) found = [(item, count) for item, count in self.item_counts if state.has(item, self.player, count)] @@ -1379,13 +1377,13 @@ class HasFromList(Rule[TWorld], game="Archipelago"): count: int = 1 """The number of items the player needs to have""" - def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) self.count = count @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_from_list return world.false_rule @@ -1395,7 +1393,7 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: "type[RuleWorldMixin]") -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1412,7 +1410,7 @@ class Resolved(Rule.Resolved): count: int = 1 @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has_from_list(self.item_names, self.player, self.count) @override @@ -1420,7 +1418,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] if state is None: messages = [ @@ -1470,7 +1468,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) found_count = state.count_from_list(self.item_names, self.player) @@ -1492,13 +1490,13 @@ def __str__(self) -> str: class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of the given items, ignoring duplicates of the same item""" - def __init__(self, *item_names: str, count: int = 1, options: "Iterable[OptionFilter[Any]]" = ()) -> None: + def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__(options=options) self.item_names: tuple[str, ...] = tuple(sorted(set(item_names))) self.count: int = count @override - def _instantiate(self, world: "TWorld") -> "Rule.Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0 or len(self.item_names) < self.count: # match state.has_from_list_unique return world.false_rule @@ -1508,11 +1506,11 @@ def _instantiate(self, world: "TWorld") -> "Rule.Resolved": class Resolved(HasFromList.Resolved): @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has_from_list_unique(self.item_names, self.player, self.count) @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] if state is None: messages = [ @@ -1562,7 +1560,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) found_count = state.count_from_list_unique(self.item_names, self.player) @@ -1591,7 +1589,7 @@ class HasGroup(Rule[TWorld], game="Archipelago"): """The number of items the player needs to have""" @override - def _instantiate(self, world: "TWorld") -> "Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: item_names = tuple(sorted(world.item_name_groups[self.item_name_group])) return self.Resolved(self.item_name_group, item_names, self.count, player=world.player) @@ -1607,7 +1605,7 @@ class Resolved(Rule.Resolved): count: int = 1 @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has_group(self.item_name_group, self.player, self.count) @override @@ -1615,7 +1613,7 @@ def item_dependencies(self) -> dict[str, set[int]]: return {item: {id(self)} for item in self.item_names} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] if state is None: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) @@ -1628,7 +1626,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) count = state.count_group(self.item_name_group, self.player) @@ -1647,11 +1645,11 @@ class HasGroupUnique(HasGroup[TWorld], game="Archipelago"): class Resolved(HasGroup.Resolved): @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has_group_unique(self.item_name_group, self.player, self.count) @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] if state is None: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) @@ -1664,7 +1662,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess return messages @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) count = state.count_group_unique(self.item_name_group, self.player) @@ -1692,7 +1690,7 @@ class CanReachLocation(Rule[TWorld], game="Archipelago"): """ @override - def _instantiate(self, world: "TWorld") -> "Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: parent_region_name = self.parent_region_name if not parent_region_name and not self.skip_indirect_connection: location = world.get_location(self.location_name) @@ -1711,7 +1709,7 @@ class Resolved(Rule.Resolved): parent_region_name: str @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.can_reach_location(self.location_name, self.player) @override @@ -1725,7 +1723,7 @@ def location_dependencies(self) -> dict[str, set[int]]: return {self.location_name: {id(self)}} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: if state is None: verb = "Can reach" elif self(state): @@ -1738,7 +1736,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess ] @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) prefix = "Reached" if self(state) else "Cannot reach" @@ -1757,7 +1755,7 @@ class CanReachRegion(Rule[TWorld], game="Archipelago"): """The name of the region to test access to""" @override - def _instantiate(self, world: "TWorld") -> "Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved(self.region_name, player=world.player) @override @@ -1769,7 +1767,7 @@ class Resolved(Rule.Resolved): region_name: str @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.can_reach_region(self.region_name, self.player) @override @@ -1777,7 +1775,7 @@ def region_dependencies(self) -> dict[str, set[int]]: return {self.region_name: {id(self)}} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: if state is None: verb = "Can reach" elif self(state): @@ -1790,7 +1788,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess ] @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) prefix = "Reached" if self(state) else "Cannot reach" @@ -1812,7 +1810,7 @@ class CanReachEntrance(Rule[TWorld], game="Archipelago"): """The name of the entrance's parent region. If not specified it will be resolved when the rule is resolved""" @override - def _instantiate(self, world: "TWorld") -> "Resolved": + def _instantiate(self, world: TWorld) -> Rule.Resolved: parent_region_name = self.parent_region_name if not parent_region_name: entrance = world.get_entrance(self.entrance_name) @@ -1831,7 +1829,7 @@ class Resolved(Rule.Resolved): parent_region_name: str @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.can_reach_entrance(self.entrance_name, self.player) @override @@ -1845,7 +1843,7 @@ def entrance_dependencies(self) -> dict[str, set[int]]: return {self.entrance_name: {id(self)}} @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: if state is None: verb = "Can reach" elif self(state): @@ -1858,7 +1856,7 @@ def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMess ] @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) prefix = "Reached" if self(state) else "Cannot reach" @@ -1870,7 +1868,7 @@ def __str__(self) -> str: DEFAULT_RULES = { - rule_name: cast("type[Rule[RuleWorldMixin]]", rule_class) + rule_name: cast(type[Rule[RuleWorldMixin]], rule_class) for rule_name, rule_class in locals().items() if isinstance(rule_class, type) and issubclass(rule_class, Rule) and rule_class is not Rule } diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index c9a9428400ac..bee9a2589fac 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -1,10 +1,11 @@ import unittest from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar from typing_extensions import override -from BaseClasses import Item, ItemClassification, Location, Region +from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region +from NetUtils import JSONMessagePart from Options import Choice, PerGameCommonOptions, Toggle from rule_builder import ( And, @@ -32,10 +33,6 @@ from worlds import network_data_package from worlds.AutoWorld import World -if TYPE_CHECKING: - from BaseClasses import CollectionState, MultiWorld - from NetUtils import JSONMessagePart - class ToggleOption(Toggle): auto_display_name: ClassVar[bool] = True @@ -177,8 +174,8 @@ def test_simplify(self) -> None: class TestOptions(unittest.TestCase): - multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] - world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] + multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] @override def setUp(self) -> None: @@ -281,9 +278,9 @@ def test_has_all_hash(self) -> None: class TestCaching(unittest.TestCase): - multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] - world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] - state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override @@ -360,9 +357,9 @@ def test_entrance_cache_busting(self) -> None: class TestCacheDisabled(unittest.TestCase): - multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] - world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] - state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override @@ -439,9 +436,9 @@ def test_entrance_logic(self) -> None: class TestRules(unittest.TestCase): - multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] - world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] - state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override @@ -771,9 +768,9 @@ def test_deserialize(self) -> None: class TestExplain(unittest.TestCase): - multiworld: "MultiWorld" # pyright: ignore[reportUninitializedInstanceVariable] - world: "RuleBuilderWorld" # pyright: ignore[reportUninitializedInstanceVariable] - state: "CollectionState" # pyright: ignore[reportUninitializedInstanceVariable] + multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 resolved_rule: ClassVar[Rule.Resolved] = And.Resolved( From ff22e7b45416f2b4c307d1daed56e3c9719e6473 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 24 Aug 2025 17:36:17 -0400 Subject: [PATCH 070/135] chore: add empty webworld for test --- test/general/test_rule_builder.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index bee9a2589fac..f4d43ecb7860 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -31,7 +31,7 @@ from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package -from worlds.AutoWorld import World +from worlds.AutoWorld import WebWorld, World class ToggleOption(Toggle): @@ -63,8 +63,13 @@ class RuleBuilderLocation(Location): game: str = GAME +class RuleBuilderWebWorld(WebWorld): + tutorials = [] # noqa: RUF012 # pyright: ignore[reportUnannotatedClassAttribute] + + class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] game: ClassVar[str] = GAME + web: ClassVar[WebWorld] = RuleBuilderWebWorld() item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} item_name_groups: ClassVar[dict[str, set[str]]] = { From 2a6eedea492d8ebfc46298cafa68392c3584adbc Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 1 Sep 2025 22:46:47 -0400 Subject: [PATCH 071/135] fix rules when caching is disabled --- worlds/astalon/logic/custom_rules.py | 343 ++++++++++---------------- worlds/astalon/logic/main_campaign.py | 8 +- worlds/astalon/world.py | 1 + 3 files changed, 138 insertions(+), 214 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index b8326df146a4..d327cba72fb5 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -1,9 +1,14 @@ import dataclasses +from collections.abc import Iterable +from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar from typing_extensions import override import rule_builder +from BaseClasses import CollectionState +from NetUtils import JSONMessagePart +from Options import Option from ..constants import GAME_NAME from ..items import ( @@ -21,6 +26,7 @@ Switch, WhiteDoor, ) +from ..locations import LocationName from ..options import ( Difficulty, Goal, @@ -31,70 +37,64 @@ RandomizeSwitches, RandomizeWhiteKeys, ) +from ..regions import RegionName if TYPE_CHECKING: - from collections.abc import Iterable - - from BaseClasses import CollectionState - from NetUtils import JSONMessagePart - from Options import Option - - from ..locations import LocationName - from ..regions import RegionName from ..world import AstalonWorld -ITEM_DEPS: "dict[str, tuple[Character, ...]]" = { - KeyItem.CLOAK.value: (Character.ALGUS,), - KeyItem.SWORD.value: (Character.ARIAS,), - KeyItem.BOOTS.value: (Character.ARIAS,), - KeyItem.CLAW.value: (Character.KYULI,), - KeyItem.BOW.value: (Character.KYULI,), - KeyItem.BLOCK.value: (Character.ZEEK,), - KeyItem.STAR.value: (Character.BRAM,), - KeyItem.BANISH.value: (Character.ALGUS, Character.ZEEK), - KeyItem.GAUNTLET.value: (Character.ARIAS, Character.BRAM), - ShopUpgrade.ALGUS_ARCANIST.value: (Character.ALGUS,), - ShopUpgrade.ALGUS_METEOR.value: (Character.ALGUS,), - ShopUpgrade.ALGUS_SHOCK.value: (Character.ALGUS,), - ShopUpgrade.ARIAS_GORGONSLAYER.value: (Character.ARIAS,), - ShopUpgrade.ARIAS_LAST_STAND.value: (Character.ARIAS,), - ShopUpgrade.ARIAS_LIONHEART.value: (Character.ARIAS,), - ShopUpgrade.KYULI_ASSASSIN.value: (Character.KYULI,), - ShopUpgrade.KYULI_BULLSEYE.value: (Character.KYULI,), - ShopUpgrade.KYULI_RAY.value: (Character.KYULI,), - ShopUpgrade.ZEEK_JUNKYARD.value: (Character.ZEEK,), - ShopUpgrade.ZEEK_ORBS.value: (Character.ZEEK,), - ShopUpgrade.ZEEK_LOOT.value: (Character.ZEEK,), - ShopUpgrade.BRAM_AXE.value: (Character.BRAM,), - ShopUpgrade.BRAM_HUNTER.value: (Character.BRAM,), - ShopUpgrade.BRAM_WHIPLASH.value: (Character.BRAM,), +ITEM_DEPS: dict[str, tuple[str, ...]] = { + KeyItem.CLOAK.value: (Character.ALGUS.value,), + KeyItem.SWORD.value: (Character.ARIAS.value,), + KeyItem.BOOTS.value: (Character.ARIAS.value,), + KeyItem.CLAW.value: (Character.KYULI.value,), + KeyItem.BOW.value: (Character.KYULI.value,), + KeyItem.BLOCK.value: (Character.ZEEK.value,), + KeyItem.STAR.value: (Character.BRAM.value,), + KeyItem.BANISH.value: (Character.ALGUS.value, Character.ZEEK.value), + KeyItem.GAUNTLET.value: (Character.ARIAS.value, Character.BRAM.value), + ShopUpgrade.ALGUS_ARCANIST.value: (Character.ALGUS.value,), + ShopUpgrade.ALGUS_METEOR.value: (Character.ALGUS.value,), + ShopUpgrade.ALGUS_SHOCK.value: (Character.ALGUS.value,), + ShopUpgrade.ARIAS_GORGONSLAYER.value: (Character.ARIAS.value,), + ShopUpgrade.ARIAS_LAST_STAND.value: (Character.ARIAS.value,), + ShopUpgrade.ARIAS_LIONHEART.value: (Character.ARIAS.value,), + ShopUpgrade.KYULI_ASSASSIN.value: (Character.KYULI.value,), + ShopUpgrade.KYULI_BULLSEYE.value: (Character.KYULI.value,), + ShopUpgrade.KYULI_RAY.value: (Character.KYULI.value,), + ShopUpgrade.ZEEK_JUNKYARD.value: (Character.ZEEK.value,), + ShopUpgrade.ZEEK_ORBS.value: (Character.ZEEK.value,), + ShopUpgrade.ZEEK_LOOT.value: (Character.ZEEK.value,), + ShopUpgrade.BRAM_AXE.value: (Character.BRAM.value,), + ShopUpgrade.BRAM_HUNTER.value: (Character.BRAM.value,), + ShopUpgrade.BRAM_WHIPLASH.value: (Character.BRAM.value,), } -VANILLA_CHARACTERS: "frozenset[Character]" = frozenset((Character.ALGUS, Character.ARIAS, Character.KYULI)) +VANILLA_CHARACTERS: frozenset[str] = frozenset((Character.ALGUS.value, Character.ARIAS.value, Character.KYULI.value)) characters_off = [rule_builder.OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)] characters_on = [rule_builder.OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")] -def _printjson_item(item: str, player: int, state: "CollectionState | None" = None) -> "JSONMessagePart": - message: JSONMessagePart = {"type": "item_name", "flags": 0b001, "text": item, "player": player} - if state: - color = "green" if state.has(item, player) else "salmon" - if item == Events.FAKE_OOL_ITEM: - color = "glitched" - message["color"] = color - return message +def as_str(value: Enum | str) -> str: + return value.value if isinstance(value, Enum) else value -@dataclasses.dataclass() -class Has(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): - item_name: "ItemName | Events" - count: int = 1 +@dataclasses.dataclass(init=False) +class Has(rule_builder.Has["AstalonWorld"], game=GAME_NAME): + @override + def __init__( + self, + item_name: ItemName | Events | str, + count: int = 1, + *, + options: Iterable[rule_builder.OptionFilter[Any]] = (), + ) -> None: + super().__init__(as_str(item_name), count, options=options) @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": - default = self.Resolved(self.item_name.value, self.count, player=world.player) + def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: + default = self.Resolved(self.item_name, self.count, player=world.player) if self.item_name in VANILLA_CHARACTERS: if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: @@ -107,45 +107,31 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": ): return default if len(deps) == 1: - return HasAll.Resolved((deps[0].value, self.item_name.value), player=world.player) + return HasAll.Resolved((deps[0], self.item_name), player=world.player) return rule_builder.Or.Resolved( - tuple(HasAll.Resolved((d.value, self.item_name.value), player=world.player) for d in deps), + tuple(world.get_cached_rule(HasAll.Resolved((d, self.item_name), player=world.player)) for d in deps), player=world.player, ) return default - @override - def __str__(self) -> str: - count = f", count={self.count}" if self.count > 1 else "" - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({self.item_name.value}{count}{options})" - - class Resolved(rule_builder.Has.Resolved): - @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages = super().explain_json(state) - messages[-1] = _printjson_item(self.item_name, self.player, state) - return messages - - -@dataclasses.dataclass() -class HasAll(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): - item_names: "tuple[ItemName | Events, ...]" +@dataclasses.dataclass(init=False) +class HasAll(rule_builder.HasAll["AstalonWorld"], game=GAME_NAME): + @override def __init__( self, - *item_names: "ItemName | Events", - options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + *item_names: ItemName | Events | str, + options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: - if len(item_names) != len(set(item_names)): - raise ValueError(f"Duplicate items detected, likely typo, items: {item_names}") + names = [as_str(name) for name in item_names] + if len(names) != len(set(names)): + raise ValueError(f"Duplicate items detected, likely typo, items: {names}") - super().__init__(options=options) - self.item_names = item_names + super().__init__(*names, options=options) @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if len(self.item_names) == 0: return rule_builder.True_.Resolved(player=world.player) if len(self.item_names) == 1: @@ -161,16 +147,16 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": continue deps = ITEM_DEPS.get(item, []) if not deps: - new_items.append(item.value) + new_items.append(item) continue if len(deps) > 1: if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: - new_items.append(item.value) + new_items.append(item) else: new_clauses.append( rule_builder.Or.Resolved( - tuple(HasAll.Resolved((d.value, item.value), player=world.player) for d in deps), + tuple(world.get_cached_rule(HasAll.Resolved((d, item), player=world.player)) for d in deps), player=world.player, ) ) @@ -184,9 +170,9 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla ) ): - new_items.append(deps[0].value) + new_items.append(deps[0]) - new_items.append(item.value) + new_items.append(item) if len(new_clauses) == 0 and len(new_items) == 0: return rule_builder.True_.Resolved(player=world.player) @@ -198,47 +184,25 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": return rule_builder.False_.Resolved(player=world.player) if len(new_clauses) == 1: return new_clauses[0] - return rule_builder.And.Resolved(tuple(new_clauses), player=world.player) - - @override - def __str__(self) -> str: - items = ", ".join([i.value for i in self.item_names]) - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({items}{options})" - - class Resolved(rule_builder.HasAll.Resolved): - @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [ - {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": "all"}, - {"type": "text", "text": " of ("}, - ] - for i, item in enumerate(self.item_names): - if i > 0: - messages.append({"type": "text", "text": ", "}) - messages.append(_printjson_item(item, self.player, state)) - messages.append({"type": "text", "text": ")"}) - return messages + return rule_builder.And.Resolved(tuple(world.get_cached_rule(c) for c in new_clauses), player=world.player) -@dataclasses.dataclass() -class HasAny(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): - item_names: "tuple[ItemName | Events, ...]" - +@dataclasses.dataclass(init=False) +class HasAny(rule_builder.HasAny["AstalonWorld"], game=GAME_NAME): + @override def __init__( self, - *item_names: "ItemName | Events", - options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + *item_names: ItemName | Events | str, + options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: - if len(item_names) != len(set(item_names)): - raise ValueError(f"Duplicate items detected, likely typo, items: {item_names}") + names = [as_str(name) for name in item_names] + if len(names) != len(set(names)): + raise ValueError(f"Duplicate items detected, likely typo, items: {names}") - super().__init__(options=options) - self.item_names = item_names + super().__init__(*names, options=options) @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if len(self.item_names) == 0: return rule_builder.True_.Resolved(player=world.player) if len(self.item_names) == 1: @@ -255,16 +219,16 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": deps = ITEM_DEPS.get(item, []) if not deps: - new_items.append(item.value) + new_items.append(item) continue if len(deps) > 1: if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: - new_items.append(item.value) + new_items.append(item) else: new_clauses.append( rule_builder.Or.Resolved( - tuple(HasAll.Resolved((d.value, item.value), player=world.player) for d in deps), + tuple(world.get_cached_rule(HasAll.Resolved((d, item), player=world.player)) for d in deps), player=world.player, ) ) @@ -278,9 +242,9 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla ) ): - new_clauses.append(HasAll.Resolved((deps[0].value, item.value), player=world.player)) + new_clauses.append(HasAll.Resolved((deps[0], item), player=world.player)) else: - new_items.append(item.value) + new_items.append(item) if len(new_items) == 1: new_clauses.append(Has.Resolved(new_items[0], player=world.player)) @@ -291,95 +255,56 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": return rule_builder.False_.Resolved(player=world.player) if len(new_clauses) == 1: return new_clauses[0] - return rule_builder.Or.Resolved(tuple(new_clauses), player=world.player) + return rule_builder.Or.Resolved(tuple(world.get_cached_rule(c) for c in new_clauses), player=world.player) - @override - def __str__(self) -> str: - items = ", ".join([i.value for i in self.item_names]) - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({items}{options})" - - class Resolved(rule_builder.HasAny.Resolved): - @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: list[JSONMessagePart] = [ - {"type": "text", "text": "Has "}, - {"type": "color", "color": "cyan", "text": "any"}, - {"type": "text", "text": " of ("}, - ] - for i, item in enumerate(self.item_names): - if i > 0: - messages.append({"type": "text", "text": ", "}) - messages.append(_printjson_item(item, self.player, state)) - messages.append({"type": "text", "text": ")"}) - return messages - - -@dataclasses.dataclass() -class CanReachLocation(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): - location_name: "LocationName" - - @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": - location = world.get_location(self.location_name) - if not location.parent_region: - raise ValueError(f"Location {location.name} has no parent region") - parent_region_name = location.parent_region.name - return self.Resolved(self.location_name.value, parent_region_name, player=world.player) +@dataclasses.dataclass(init=False) +class CanReachLocation(rule_builder.CanReachLocation["AstalonWorld"], game=GAME_NAME): @override - def __str__(self) -> str: - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({self.location_name.value}{options})" - - class Resolved(rule_builder.CanReachLocation.Resolved): - pass - - -@dataclasses.dataclass() -class CanReachRegion(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): - region_name: "RegionName" + def __init__( + self, + location_name: LocationName | str, + parent_region_name: RegionName | str = "", + skip_indirect_connection: bool = False, + *, + options: Iterable[rule_builder.OptionFilter[Any]] = (), + ) -> None: + super().__init__(as_str(location_name), as_str(parent_region_name), skip_indirect_connection, options=options) - @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": - return self.Resolved(self.region_name.value, player=world.player) +@dataclasses.dataclass(init=False) +class CanReachRegion(rule_builder.CanReachRegion["AstalonWorld"], game=GAME_NAME): @override - def __str__(self) -> str: - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({self.region_name.value}{options})" - - class Resolved(rule_builder.CanReachRegion.Resolved): - pass - + def __init__( + self, + region_name: RegionName | str, + *, + options: Iterable[rule_builder.OptionFilter[Any]] = (), + ) -> None: + super().__init__(as_str(region_name), options=options) -@dataclasses.dataclass() -class CanReachEntrance(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): - from_region: "RegionName" - to_region: "RegionName" - - @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": - parent_region = world.get_region(self.from_region.value) - entrance = f"{self.from_region.value} -> {self.to_region.value}" - return self.Resolved(entrance, parent_region.name, player=world.player) +@dataclasses.dataclass(init=False) +class CanReachEntrance(rule_builder.CanReachEntrance["AstalonWorld"], game=GAME_NAME): @override - def __str__(self) -> str: - options = f", options={self.options}" if self.options else "" - return f"{self.__class__.__name__}({self.from_region.value} -> {self.to_region.value}{options})" - - class Resolved(rule_builder.CanReachEntrance.Resolved): - pass + def __init__( + self, + from_region: RegionName | str, + to_region: RegionName | str, + *, + options: Iterable[rule_builder.OptionFilter[Any]] = (), + ) -> None: + entrance_name = f"{as_str(from_region)} -> {as_str(to_region)}" + super().__init__(entrance_name, as_str(from_region), options=options) @dataclasses.dataclass(init=False) class ToggleRule(HasAll, game=GAME_NAME): - option_cls: "ClassVar[type[Option[int]]]" + option_cls: ClassVar[type[Option[int]]] otherwise: bool = False @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if len(self.item_names) == 1: rule = Has(self.item_names[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) else: @@ -396,13 +321,13 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": @dataclasses.dataclass(init=False) class HasWhite(ToggleRule, game=GAME_NAME): - option_cls: "ClassVar[type[Option[int]]]" = RandomizeWhiteKeys + option_cls: ClassVar[type[Option[int]]] = RandomizeWhiteKeys def __init__( self, - *doors: "WhiteDoor", + *doors: WhiteDoor, otherwise: bool = False, - options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -410,13 +335,13 @@ def __init__( @dataclasses.dataclass(init=False) class HasBlue(ToggleRule, game=GAME_NAME): - option_cls: "ClassVar[type[Option[int]]]" = RandomizeBlueKeys + option_cls: ClassVar[type[Option[int]]] = RandomizeBlueKeys def __init__( self, - *doors: "BlueDoor", + *doors: BlueDoor, otherwise: bool = False, - options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -424,13 +349,13 @@ def __init__( @dataclasses.dataclass(init=False) class HasRed(ToggleRule, game=GAME_NAME): - option_cls: "ClassVar[type[Option[int]]]" = RandomizeRedKeys + option_cls: ClassVar[type[Option[int]]] = RandomizeRedKeys def __init__( self, - *doors: "RedDoor", + *doors: RedDoor, otherwise: bool = False, - options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -438,13 +363,13 @@ def __init__( @dataclasses.dataclass(init=False) class HasSwitch(ToggleRule, game=GAME_NAME): - option_cls: "ClassVar[type[Option[int]]]" = RandomizeSwitches + option_cls: ClassVar[type[Option[int]]] = RandomizeSwitches def __init__( self, - *switches: "Switch | Crystal | Face", + *switches: Switch | Crystal | Face, otherwise: bool = False, - options: "Iterable[rule_builder.OptionFilter[Any]]" = (), + options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: super().__init__(*switches, options=options) self.otherwise: bool = otherwise @@ -452,7 +377,7 @@ def __init__( @dataclasses.dataclass(init=False) class HasElevator(HasAll, game=GAME_NAME): - def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.OptionFilter[Any]]" = ()) -> None: + def __init__(self, elevator: Elevator, *, options: Iterable[rule_builder.OptionFilter[Any]] = ()) -> None: super().__init__( KeyItem.ASCENDANT_KEY, elevator, @@ -463,7 +388,7 @@ def __init__(self, elevator: "Elevator", *, options: "Iterable[rule_builder.Opti @dataclasses.dataclass() class HasGoal(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if world.options.goal.value != Goal.option_eye_hunt: return rule_builder.True_.Resolved(player=world.player) return Has.Resolved( @@ -476,16 +401,16 @@ def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": @dataclasses.dataclass() class HardLogic(rule_builder.Wrapper["AstalonWorld"], game=GAME_NAME): @override - def _instantiate(self, world: "AstalonWorld") -> "rule_builder.Rule.Resolved": + def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if world.options.difficulty.value == Difficulty.option_hard: return self.child.resolve(world) if getattr(world.multiworld, "generation_is_fake", False): - return self.Resolved(self.child.resolve(world), player=world.player) + return self.Resolved(world.get_cached_rule(self.child.resolve(world)), player=world.player) return rule_builder.False_.Resolved(player=world.player) class Resolved(rule_builder.Wrapper.Resolved): @override - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child(state) @override @@ -495,8 +420,8 @@ def item_dependencies(self) -> dict[str, set[int]]: return deps @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": - messages: "list[JSONMessagePart]" = [ + def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: + messages: list[JSONMessagePart] = [ {"type": "color", "color": "glitched", "text": "Hard Logic ["}, ] messages.extend(self.child.explain_json(state)) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 8e445a1292ec..041ee6f44d0d 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from rule_builder import And, OptionFilter, Or, True_ +from rule_builder import And, OptionFilter, Or, Rule, True_ from ..items import ( BlueDoor, @@ -43,8 +43,6 @@ ) if TYPE_CHECKING: - from rule_builder import Rule - from ..world import AstalonWorld easy = [OptionFilter(Difficulty, Difficulty.option_easy)] @@ -102,7 +100,7 @@ shop_moderate = CanReachRegion(R.MECH_START) shop_expensive = CanReachRegion(R.ROA_START) -MAIN_ENTRANCE_RULES: "dict[tuple[R, R], Rule[AstalonWorld]]" = { +MAIN_ENTRANCE_RULES: dict[tuple[R, R], Rule["AstalonWorld"]] = { (R.SHOP, R.SHOP_ALGUS): Has(Character.ALGUS), (R.SHOP, R.SHOP_ARIAS): Has(Character.ARIAS), (R.SHOP, R.SHOP_KYULI): Has(Character.KYULI), @@ -1057,7 +1055,7 @@ (R.SP_STAR_END, R.SP_STAR_CONNECTION): And(Has(KeyItem.STAR), HasSwitch(Switch.SP_AFTER_STAR)), } -MAIN_LOCATION_RULES: "dict[L, Rule[AstalonWorld]]" = { +MAIN_LOCATION_RULES: dict[L, Rule["AstalonWorld"]] = { L.GT_GORGONHEART: Or( HasSwitch(Switch.GT_GH, otherwise=True), HasAny(Character.KYULI, KeyItem.ICARUS, KeyItem.BLOCK, KeyItem.CLOAK, KeyItem.BOOTS), diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 01b4d82d2a44..a1684ad6aaa4 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -127,6 +127,7 @@ class AstalonWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultip item_name_to_id: ClassVar[dict[str, int]] = item_name_to_id location_name_to_id: ClassVar[dict[str, int]] = location_name_to_id required_client_version: tuple[int, int, int] = (0, 6, 0) + rule_caching_enabled: ClassVar[bool] = True starting_characters: list[Character] extra_gold_eyes: int = 0 From 35b95ebc733b91a2c08b73cf70c55108f0f2136d Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 3 Sep 2025 21:22:46 -0400 Subject: [PATCH 072/135] chore: optimize rule evaluations --- rule_builder.py | 71 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 85800d91fe55..d6f6654ffa70 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -924,7 +924,8 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has(self.item_name, self.player, count=self.count) + # implementation based on state.has + return state.prog_items[self.player][self.item_name] >= self.count @override def item_dependencies(self) -> dict[str, set[int]]: @@ -1001,7 +1002,12 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has_all(self.item_names, self.player) + # implementation based on state.has_all + player_prog_items = state.prog_items[self.player] + for item in self.item_names: + if not player_prog_items[item]: + return False + return True @override def item_dependencies(self) -> dict[str, set[int]]: @@ -1109,7 +1115,12 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has_any(self.item_names, self.player) + # implementation based on state.has_any + player_prog_items = state.prog_items[self.player] + for item in self.item_names: + if player_prog_items[item]: + return True + return False @override def item_dependencies(self) -> dict[str, set[int]]: @@ -1206,9 +1217,12 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - # it will certainly be faster to reimplement has_all_counts here - # I'm leaving it for now so I can benchmark it later - return state.has_all_counts(dict(self.item_counts), self.player) + # implementation based on state.has_all_counts + player_prog_items = state.prog_items[self.player] + for item, count in self.item_counts: + if player_prog_items[item] < count: + return False + return True @override def item_dependencies(self) -> dict[str, set[int]]: @@ -1297,9 +1311,12 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: class Resolved(HasAllCounts.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - # it will certainly be faster to reimplement has_all_counts here - # I'm leaving it for now so I can benchmark it later - return state.has_any_count(dict(self.item_counts), self.player) + # implementation based on state.has_any_count + player_prog_items = state.prog_items[self.player] + for item, count in self.item_counts: + if player_prog_items[item] >= count: + return True + return False @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: @@ -1411,7 +1428,14 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has_from_list(self.item_names, self.player, self.count) + # implementation based on state.has_from_list + found = 0 + player_prog_items = state.prog_items[self.player] + for item_name in self.item_names: + found += player_prog_items[item_name] + if found >= self.count: + return True + return False @override def item_dependencies(self) -> dict[str, set[int]]: @@ -1507,7 +1531,14 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: class Resolved(HasFromList.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has_from_list_unique(self.item_names, self.player, self.count) + # implementation based on state.has_from_list_unique + found = 0 + player_prog_items = state.prog_items[self.player] + for item_name in self.item_names: + found += player_prog_items[item_name] > 0 + if found >= self.count: + return True + return False @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: @@ -1606,7 +1637,14 @@ class Resolved(Rule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has_group(self.item_name_group, self.player, self.count) + # implementation based on state.has_group + found = 0 + player_prog_items = state.prog_items[self.player] + for item_name in self.item_names: + found += player_prog_items[item_name] + if found >= self.count: + return True + return False @override def item_dependencies(self) -> dict[str, set[int]]: @@ -1646,7 +1684,14 @@ class HasGroupUnique(HasGroup[TWorld], game="Archipelago"): class Resolved(HasGroup.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: - return state.has_group_unique(self.item_name_group, self.player, self.count) + # implementation based on state.has_group_unique + found = 0 + player_prog_items = state.prog_items[self.player] + for item_name in self.item_names: + found += player_prog_items[item_name] > 0 + if found >= self.count: + return True + return False @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: From 69a43d07ed209a8511208858470e5ab4393d17fc Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 9 Sep 2025 18:36:54 -0400 Subject: [PATCH 073/135] remove getattr from hot code paths --- rule_builder.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index d6f6654ffa70..f5ee90d7877b 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -292,7 +292,7 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": @override def collect(self, state: CollectionState, item: Item) -> bool: changed = super().collect(state, item) - if changed and self.rule_caching_enabled and getattr(self, "rule_item_dependencies", None): + if changed and self.rule_caching_enabled and self.rule_item_dependencies: player_results = state.rule_cache[self.player] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] @@ -308,26 +308,26 @@ def remove(self, state: CollectionState, item: Item) -> bool: return changed player_results = state.rule_cache[self.player] - if getattr(self, "rule_item_dependencies", None): + if self.rule_item_dependencies: mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] for rule_id in rule_ids: player_results.pop(rule_id, None) # clear all region dependent caches as none can be trusted - if getattr(self, "rule_region_dependencies", None): + if self.rule_region_dependencies: for rule_ids in self.rule_region_dependencies.values(): for rule_id in rule_ids: player_results.pop(rule_id, None) # clear all location dependent caches as they may have lost region access - if getattr(self, "rule_location_dependencies", None): + if self.rule_location_dependencies: for rule_ids in self.rule_location_dependencies.values(): for rule_id in rule_ids: player_results.pop(rule_id, None) # clear all entrance dependent caches as they may have lost region access - if getattr(self, "rule_entrance_dependencies", None): + if self.rule_entrance_dependencies: for rule_ids in self.rule_entrance_dependencies.values(): for rule_id in rule_ids: player_results.pop(rule_id, None) @@ -337,7 +337,7 @@ def remove(self, state: CollectionState, item: Item) -> bool: @override def reached_region(self, state: CollectionState, region: Region) -> None: super().reached_region(state, region) - if self.rule_caching_enabled and getattr(self, "rule_region_dependencies", None): + if self.rule_caching_enabled and self.rule_region_dependencies: player_results = state.rule_cache[self.player] for rule_id in self.rule_region_dependencies[region.name]: player_results.pop(rule_id, None) From fcb68facee701b7240fb7ce7749e1c79bcaef019 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 14 Sep 2025 15:15:24 -0400 Subject: [PATCH 074/135] testing new cache flags --- rule_builder.py | 187 ++++++++++++++++++++++-------- test/general/test_rule_builder.py | 23 +++- 2 files changed, 158 insertions(+), 52 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index f5ee90d7877b..41279dcebb54 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -59,8 +59,8 @@ def __init__(self, multiworld: MultiWorld, player: int) -> None: self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) self.rule_entrance_dependencies = defaultdict(set) - self.true_rule = self.get_cached_rule(True_.Resolved(player=self.player)) - self.false_rule = self.get_cached_rule(False_.Resolved(player=self.player)) + self.true_rule = self.resolve_rule(True_()) + self.false_rule = self.resolve_rule(False_()) @classmethod def get_rule_cls(cls, name: str) -> type["Rule[Self]"]: @@ -77,15 +77,11 @@ def rule_from_dict(cls, data: Mapping[str, Any]) -> "Rule[Self]": def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": """Returns a resolved rule registered with the caching system for this world""" resolved_rule = rule.resolve(self) - resolved_rule = self.get_cached_rule(resolved_rule) - return self.simplify_rule(resolved_rule) + resolved_rule = self.simplify_rule(resolved_rule) + return self.get_cached_rule(resolved_rule) def get_cached_rule(self, resolved_rule: "Rule.Resolved") -> "Rule.Resolved": """Returns a cached instance of a resolved rule based on the hash""" - if not self.rule_caching_enabled: - # skip the caching logic entirely - object.__setattr__(resolved_rule, "cacheable", False) - object.__setattr__(resolved_rule, "__call__", resolved_rule._evaluate) # pyright: ignore[reportPrivateUsage] rule_hash = hash(resolved_rule) if rule_hash in self.rule_ids: return self.rule_ids[rule_hash] @@ -223,20 +219,32 @@ def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": if count == 1: has_all_items.append(item) else: - clauses.append(self.get_cached_rule(has_cls.Resolved(item, count, player=rule.player))) + clauses.append( + self.get_cached_rule( + has_cls.Resolved(item, count, player=rule.player, caching_enabled=self.rule_caching_enabled) + ) + ) if len(has_all_items) == 1: - clauses.append(self.get_cached_rule(has_cls.Resolved(has_all_items[0], player=rule.player))) + clauses.append( + self.get_cached_rule( + has_cls.Resolved(has_all_items[0], player=rule.player, caching_enabled=self.rule_caching_enabled) + ) + ) elif len(has_all_items) > 1: - clauses.append(self.get_cached_rule(has_all_cls.Resolved(tuple(has_all_items), player=rule.player))) + clauses.append( + self.get_cached_rule( + has_all_cls.Resolved( + tuple(has_all_items), + player=rule.player, + caching_enabled=self.rule_caching_enabled, + ) + ) + ) if len(clauses) == 1: return clauses[0] - return And.Resolved( - tuple(clauses), - player=rule.player, - cacheable=rule.cacheable and all(c.cacheable for c in clauses), - ) + return And.Resolved(tuple(clauses), player=rule.player, caching_enabled=self.rule_caching_enabled) def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": children_to_process = list(rule.children) @@ -274,20 +282,32 @@ def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": if count == 1: has_any_items.append(item) else: - clauses.append(self.get_cached_rule(has_cls.Resolved(item, count, player=rule.player))) + clauses.append( + self.get_cached_rule( + has_cls.Resolved(item, count, player=rule.player, caching_enabled=self.rule_caching_enabled) + ) + ) if len(has_any_items) == 1: - clauses.append(self.get_cached_rule(has_cls.Resolved(has_any_items[0], player=rule.player))) + clauses.append( + self.get_cached_rule( + has_cls.Resolved(has_any_items[0], player=rule.player, caching_enabled=self.rule_caching_enabled) + ) + ) elif len(has_any_items) > 1: - clauses.append(self.get_cached_rule(has_any_cls.Resolved(tuple(has_any_items), player=rule.player))) + clauses.append( + self.get_cached_rule( + has_any_cls.Resolved( + tuple(has_any_items), + player=rule.player, + caching_enabled=self.rule_caching_enabled, + ) + ) + ) if len(clauses) == 1: return clauses[0] - return Or.Resolved( - tuple(clauses), - player=rule.player, - cacheable=rule.cacheable and all(c.cacheable for c in clauses), - ) + return Or.Resolved(tuple(clauses), player=rule.player, caching_enabled=self.rule_caching_enabled) @override def collect(self, state: CollectionState, item: Item) -> bool: @@ -492,7 +512,7 @@ def __post_init__(self) -> None: def _instantiate(self, world: TWorld) -> "Resolved": """Create a new resolved rule for this world""" - return self.Resolved(player=world.player) + return self.Resolved(player=world.player, caching_enabled=world.rule_caching_enabled) def resolve(self, world: TWorld) -> "Resolved": """Resolve a rule with the given world""" @@ -571,8 +591,17 @@ class Resolved(metaclass=CustomRuleRegister): player: int """The player this rule is for""" - cacheable: bool = dataclasses.field(repr=False, default=True, kw_only=True) - """If this rule should be cached in the state""" + caching_enabled: bool = dataclasses.field(repr=False, default=True, kw_only=True) + """If the world this rule is for has caching enabled""" + + force_recalculate: ClassVar[bool] = False + """Forces this rule to be recalculated every time it is evaluated. + Forces any parent composite rules containing this rule to also be recalculated. Implies skip_cache.""" + + skip_cache: ClassVar[bool] = False + """Skips the caching layer when evaluating this rule. + Composite rules will still respect the caching layer so dependencies functions should be implemented as normal. + Set to True when rule calculation is trivial.""" always_true: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" @@ -580,16 +609,24 @@ class Resolved(metaclass=CustomRuleRegister): always_false: ClassVar[bool] = False """Whether this rule always evaluates to True, used to short-circuit logic""" + def __post_init__(self) -> None: + object.__setattr__( + self, + "caching_enabled", + self.caching_enabled and not self.force_recalculate and not self.skip_cache, + ) + def __call__(self, state: CollectionState) -> bool: """Evaluate this rule's result with the given state, using the cached value if possible""" - cached_result = None - if self.cacheable: - cached_result = state.rule_cache[self.player].get(id(self)) + if not self.caching_enabled: + return self._evaluate(state) + + cached_result = state.rule_cache[self.player].get(id(self)) if cached_result is not None: return cached_result + result = self._evaluate(state) - if self.cacheable: - state.rule_cache[self.player][id(self)] = result + state.rule_cache[self.player][id(self)] = result return result def _evaluate(self, state: CollectionState) -> bool: @@ -632,6 +669,7 @@ class True_(Rule[TWorld], game="Archipelago"): # noqa: N801 class Resolved(Rule.Resolved): always_true: ClassVar[bool] = True + skip_cache: ClassVar[bool] = True @override def _evaluate(self, state: CollectionState) -> bool: @@ -652,6 +690,7 @@ class False_(Rule[TWorld], game="Archipelago"): # noqa: N801 class Resolved(Rule.Resolved): always_false: ClassVar[bool] = True + skip_cache: ClassVar[bool] = True @override def _evaluate(self, state: CollectionState) -> bool: @@ -680,7 +719,7 @@ def __init__(self, *children: Rule[TWorld], options: Iterable[OptionFilter[Any]] @override def _instantiate(self, world: TWorld) -> Rule.Resolved: children = [world.resolve_rule(c) for c in self.children] - return self.Resolved(tuple(children), player=world.player) + return self.Resolved(tuple(children), player=world.player, caching_enabled=world.rule_caching_enabled) @override def to_dict(self) -> dict[str, Any]: @@ -705,6 +744,14 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): children: tuple[Rule.Resolved, ...] + def __post_init__(self) -> None: + object.__setattr__( + self, + "force_recalculate", + self.force_recalculate or any(c.force_recalculate for c in self.children), + ) + super().__post_init__() + @override def item_dependencies(self) -> dict[str, set[int]]: combined_deps: dict[str, set[int]] = {} @@ -825,7 +872,11 @@ class Wrapper(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - return self.Resolved(world.resolve_rule(self.child), player=world.player) + return self.Resolved( + world.resolve_rule(self.child), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override def to_dict(self) -> dict[str, Any]: @@ -850,6 +901,10 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): child: Rule.Resolved + def __post_init__(self) -> None: + object.__setattr__(self, "force_recalculate", self.force_recalculate or self.child.force_recalculate) + super().__post_init__() + @override def _evaluate(self, state: CollectionState) -> bool: return self.child(state) @@ -910,7 +965,12 @@ class Has(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - return self.Resolved(self.item_name, self.count, player=world.player) + return self.Resolved( + self.item_name, + self.count, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override def __str__(self) -> str: @@ -921,6 +981,7 @@ def __str__(self) -> str: class Resolved(Rule.Resolved): item_name: str count: int = 1 + skip_cache: ClassVar[bool] = True @override def _evaluate(self, state: CollectionState) -> bool: @@ -929,7 +990,7 @@ def _evaluate(self, state: CollectionState) -> bool: @override def item_dependencies(self) -> dict[str, set[int]]: - return {self.item_name: {id(self)}} + return {self.item_name: set()} @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: @@ -981,7 +1042,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return world.true_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) - return self.Resolved(self.item_names, player=world.player) + return self.Resolved(self.item_names, player=world.player, caching_enabled=world.rule_caching_enabled) @override @classmethod @@ -1094,7 +1155,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return world.false_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) - return self.Resolved(self.item_names, player=world.player) + return self.Resolved(self.item_names, player=world.player, caching_enabled=world.rule_caching_enabled) @override @classmethod @@ -1204,7 +1265,11 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) - return self.Resolved(tuple(self.item_counts.items()), player=world.player) + return self.Resolved( + tuple(self.item_counts.items()), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override def __str__(self) -> str: @@ -1306,7 +1371,11 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) - return self.Resolved(tuple(self.item_counts.items()), player=world.player) + return self.Resolved( + tuple(self.item_counts.items()), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) class Resolved(HasAllCounts.Resolved): @override @@ -1406,7 +1475,12 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return world.false_rule if len(self.item_names) == 1: return Has(self.item_names[0], self.count).resolve(world) - return self.Resolved(self.item_names, self.count, player=world.player) + return self.Resolved( + self.item_names, + self.count, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override @classmethod @@ -1526,7 +1600,12 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return world.false_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) - return self.Resolved(self.item_names, self.count, player=world.player) + return self.Resolved( + self.item_names, + self.count, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) class Resolved(HasFromList.Resolved): @override @@ -1622,7 +1701,13 @@ class HasGroup(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: item_names = tuple(sorted(world.item_name_groups[self.item_name_group])) - return self.Resolved(self.item_name_group, item_names, self.count, player=world.player) + return self.Resolved( + self.item_name_group, + item_names, + self.count, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override def __str__(self) -> str: @@ -1742,7 +1827,12 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if not location.parent_region: raise ValueError(f"Location {location.name} has no parent region") parent_region_name = location.parent_region.name - return self.Resolved(self.location_name, parent_region_name, player=world.player) + return self.Resolved( + self.location_name, + parent_region_name, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override def __str__(self) -> str: @@ -1801,7 +1891,7 @@ class CanReachRegion(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - return self.Resolved(self.region_name, player=world.player) + return self.Resolved(self.region_name, player=world.player, caching_enabled=world.rule_caching_enabled) @override def __str__(self) -> str: @@ -1862,7 +1952,12 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if not entrance.parent_region: raise ValueError(f"Entrance {entrance.name} has no parent region") parent_region_name = entrance.parent_region.name - return self.Resolved(self.entrance_name, parent_region_name, player=world.player) + return self.Resolved( + self.entrance_name, + parent_region_name, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @override def __str__(self) -> str: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index f4d43ecb7860..335dfb9922f2 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -320,13 +320,15 @@ def setUp(self) -> None: return super().setUp() def test_item_cache_busting(self) -> None: - entrance = self.world.get_entrance("Region 1 -> Region 2") - self.assertFalse(entrance.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(entrance.access_rule)]) + location = self.world.get_location("Location 4") + self.state.collect(self.world.create_item("Item 1")) # access to region 2 + self.state.collect(self.world.create_item("Item 2")) # item directly needed + self.assertFalse(location.can_reach(self.state)) # populates cache + self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) - self.state.collect(self.world.create_item("Item 1")) # clears cache, item directly needed - self.assertNotIn(id(entrance.access_rule), self.state.rule_cache[1]) - self.assertTrue(entrance.can_reach(self.state)) + self.state.collect(self.world.create_item("Item 3")) # clears cache, item directly needed + self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) + self.assertTrue(location.can_reach(self.state)) def test_region_cache_busting(self) -> None: location = self.world.get_location("Location 2") @@ -360,6 +362,15 @@ def test_entrance_cache_busting(self) -> None: self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) self.assertTrue(location.can_reach(self.state)) + def test_has_skips_cache(self) -> None: + entrance = self.world.get_entrance("Region 1 -> Region 2") + self.assertFalse(entrance.can_reach(self.state)) # does not populates cache + self.assertNotIn(id(entrance.access_rule), self.state.rule_cache[1]) + + self.state.collect(self.world.create_item("Item 1")) # no need to clear cache, item directly needed + self.assertNotIn(id(entrance.access_rule), self.state.rule_cache[1]) + self.assertTrue(entrance.can_reach(self.state)) + class TestCacheDisabled(unittest.TestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] From ff6561099373c7b432b303c2f01f39915fa58b6d Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 17 Sep 2025 23:06:00 -0400 Subject: [PATCH 075/135] only clear cache for rules cached as false in collect --- rule_builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rule_builder.py b/rule_builder.py index 41279dcebb54..f30ee6ca5e34 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -317,7 +317,8 @@ def collect(self, state: CollectionState, item: Item) -> bool: mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] for rule_id in rule_ids: - player_results.pop(rule_id, None) + if player_results.get(rule_id, None) is False: + del player_results[rule_id] return changed From a0ff983ad02f32d8a189d6839c7a680ce2502b56 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 17 Sep 2025 23:52:23 -0400 Subject: [PATCH 076/135] update test for new behaviour --- test/general/test_rule_builder.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 335dfb9922f2..60b63dddf723 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -329,6 +329,9 @@ def test_item_cache_busting(self) -> None: self.state.collect(self.world.create_item("Item 3")) # clears cache, item directly needed self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) self.assertTrue(location.can_reach(self.state)) + self.assertTrue(self.state.rule_cache[1][id(location.access_rule)]) + self.state.collect(self.world.create_item("Item 3")) # does not clear cache as rule is already true + self.assertTrue(self.state.rule_cache[1][id(location.access_rule)]) def test_region_cache_busting(self) -> None: location = self.world.get_location("Location 2") From 498dde626e27efd8ebe632cb7732ac90e5a28451 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 18 Sep 2025 21:12:34 -0400 Subject: [PATCH 077/135] fix custom rules --- worlds/astalon/logic/custom_rules.py | 106 +++++++++++++++++++++------ 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index d327cba72fb5..6a4daa0191d2 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -94,11 +94,16 @@ def __init__( @override def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: - default = self.Resolved(self.item_name, self.count, player=world.player) + default = self.Resolved( + self.item_name, + self.count, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) if self.item_name in VANILLA_CHARACTERS: if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: - return rule_builder.True_.Resolved(player=world.player) + return world.true_rule return default if deps := ITEM_DEPS.get(self.item_name): @@ -107,10 +112,24 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: ): return default if len(deps) == 1: - return HasAll.Resolved((deps[0], self.item_name), player=world.player) + return HasAll.Resolved( + (deps[0], self.item_name), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) return rule_builder.Or.Resolved( - tuple(world.get_cached_rule(HasAll.Resolved((d, self.item_name), player=world.player)) for d in deps), + tuple( + world.get_cached_rule( + HasAll.Resolved( + (d, self.item_name), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) + ) + for d in deps + ), player=world.player, + caching_enabled=world.rule_caching_enabled, ) return default @@ -133,7 +152,7 @@ def __init__( @override def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if len(self.item_names) == 0: - return rule_builder.True_.Resolved(player=world.player) + return world.true_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) @@ -156,8 +175,18 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: else: new_clauses.append( rule_builder.Or.Resolved( - tuple(world.get_cached_rule(HasAll.Resolved((d, item), player=world.player)) for d in deps), + tuple( + world.get_cached_rule( + HasAll.Resolved( + (d, item), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) + ) + for d in deps + ), player=world.player, + caching_enabled=world.rule_caching_enabled, ) ) continue @@ -175,16 +204,24 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: new_items.append(item) if len(new_clauses) == 0 and len(new_items) == 0: - return rule_builder.True_.Resolved(player=world.player) + return world.true_rule if len(new_items) == 1: - new_clauses.append(Has.Resolved(new_items[0], player=world.player)) + new_clauses.append( + Has.Resolved(new_items[0], player=world.player, caching_enabled=world.rule_caching_enabled) + ) elif len(new_items) > 1: - new_clauses.append(HasAll.Resolved(tuple(new_items), player=world.player)) + new_clauses.append( + HasAll.Resolved(tuple(new_items), player=world.player, caching_enabled=world.rule_caching_enabled) + ) if len(new_clauses) == 0: - return rule_builder.False_.Resolved(player=world.player) + return rule_builder.False_.Resolved(player=world.player, caching_enabled=world.rule_caching_enabled) if len(new_clauses) == 1: return new_clauses[0] - return rule_builder.And.Resolved(tuple(world.get_cached_rule(c) for c in new_clauses), player=world.player) + return rule_builder.And.Resolved( + tuple(world.get_cached_rule(c) for c in new_clauses), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @dataclasses.dataclass(init=False) @@ -204,7 +241,7 @@ def __init__( @override def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if len(self.item_names) == 0: - return rule_builder.True_.Resolved(player=world.player) + return world.true_rule if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) @@ -215,7 +252,7 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: item in VANILLA_CHARACTERS and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla ): - return rule_builder.True_.Resolved(player=world.player) + return world.true_rule deps = ITEM_DEPS.get(item, []) if not deps: @@ -228,8 +265,18 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: else: new_clauses.append( rule_builder.Or.Resolved( - tuple(world.get_cached_rule(HasAll.Resolved((d, item), player=world.player)) for d in deps), + tuple( + world.get_cached_rule( + HasAll.Resolved( + (d, item), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) + ) + for d in deps + ), player=world.player, + caching_enabled=world.rule_caching_enabled, ) ) continue @@ -242,20 +289,30 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla ) ): - new_clauses.append(HasAll.Resolved((deps[0], item), player=world.player)) + new_clauses.append( + HasAll.Resolved((deps[0], item), player=world.player, caching_enabled=world.rule_caching_enabled) + ) else: new_items.append(item) if len(new_items) == 1: - new_clauses.append(Has.Resolved(new_items[0], player=world.player)) + new_clauses.append( + Has.Resolved(new_items[0], player=world.player, caching_enabled=world.rule_caching_enabled) + ) elif len(new_items) > 1: - new_clauses.append(HasAny.Resolved(tuple(new_items), player=world.player)) + new_clauses.append( + HasAny.Resolved(tuple(new_items), player=world.player, caching_enabled=world.rule_caching_enabled) + ) if len(new_clauses) == 0: - return rule_builder.False_.Resolved(player=world.player) + return rule_builder.False_.Resolved(player=world.player, caching_enabled=world.rule_caching_enabled) if len(new_clauses) == 1: return new_clauses[0] - return rule_builder.Or.Resolved(tuple(world.get_cached_rule(c) for c in new_clauses), player=world.player) + return rule_builder.Or.Resolved( + tuple(world.get_cached_rule(c) for c in new_clauses), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) @dataclasses.dataclass(init=False) @@ -390,11 +447,12 @@ class HasGoal(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): @override def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if world.options.goal.value != Goal.option_eye_hunt: - return rule_builder.True_.Resolved(player=world.player) + return world.true_rule return Has.Resolved( Eye.GOLD.value, count=world.options.additional_eyes_required.value, player=world.player, + caching_enabled=world.rule_caching_enabled, ) @@ -405,8 +463,12 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if world.options.difficulty.value == Difficulty.option_hard: return self.child.resolve(world) if getattr(world.multiworld, "generation_is_fake", False): - return self.Resolved(world.get_cached_rule(self.child.resolve(world)), player=world.player) - return rule_builder.False_.Resolved(player=world.player) + return self.Resolved( + world.get_cached_rule(self.child.resolve(world)), + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) + return world.false_rule class Resolved(rule_builder.Wrapper.Resolved): @override From ad42a914d5d5863e1b1f27bf51bfdf55cfec81ff Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 19 Sep 2025 21:51:34 -0400 Subject: [PATCH 078/135] do not have rules inherit from each other --- rule_builder.py | 89 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index f30ee6ca5e34..62f7a76f4e4a 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1361,9 +1361,12 @@ def __str__(self) -> str: @dataclasses.dataclass() -class HasAnyCount(HasAllCounts[TWorld], game="Archipelago"): +class HasAnyCount(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has any of the specified counts of the given items""" + item_counts: dict[str, int] + """A mapping of item name to count to check for""" + @override def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 0: @@ -1378,7 +1381,15 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: caching_enabled=world.rule_caching_enabled, ) - class Resolved(HasAllCounts.Resolved): + @override + def __str__(self) -> str: + items = ", ".join([f"{item} x{count}" for item, count in self.item_counts.items()]) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}{options})" + + class Resolved(Rule.Resolved): + item_counts: tuple[tuple[str, int], ...] + @override def _evaluate(self, state: CollectionState) -> bool: # implementation based on state.has_any_count @@ -1388,6 +1399,10 @@ def _evaluate(self, state: CollectionState) -> bool: return True return False + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item, _ in self.item_counts} + @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] @@ -1586,13 +1601,19 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) -class HasFromListUnique(HasFromList[TWorld], game="Archipelago"): +class HasFromListUnique(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of the given items, ignoring duplicates of the same item""" + item_names: tuple[str, ...] + """A tuple of item names to check for""" + + count: int = 1 + """The number of items the player needs to have""" + def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__(options=options) - self.item_names: tuple[str, ...] = tuple(sorted(set(item_names))) - self.count: int = count + self.item_names = tuple(sorted(set(item_names))) + self.count = count @override def _instantiate(self, world: TWorld) -> Rule.Resolved: @@ -1608,7 +1629,24 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: caching_enabled=world.rule_caching_enabled, ) - class Resolved(HasFromList.Resolved): + @override + @classmethod + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + args = {**data.get("args", {})} + item_names = args.pop("item_names", ()) + options = OptionFilter.multiple_from_dict(data.get("options", ())) + return cls(*item_names, **args, options=options) + + @override + def __str__(self) -> str: + items = ", ".join(self.item_names) + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({items}, count={self.count}{options})" + + class Resolved(Rule.Resolved): + item_names: tuple[str, ...] + count: int = 1 + @override def _evaluate(self, state: CollectionState) -> bool: # implementation based on state.has_from_list_unique @@ -1620,6 +1658,10 @@ def _evaluate(self, state: CollectionState) -> bool: return True return False + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.item_names} + @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [] @@ -1763,11 +1805,38 @@ def __str__(self) -> str: @dataclasses.dataclass() -class HasGroupUnique(HasGroup[TWorld], game="Archipelago"): +class HasGroupUnique(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of the items present in the specified item group, ignoring duplicates of the same item""" - class Resolved(HasGroup.Resolved): + item_name_group: str + """The name of the item group containing the items""" + + count: int = 1 + """The number of items the player needs to have""" + + @override + def _instantiate(self, world: TWorld) -> Rule.Resolved: + item_names = tuple(sorted(world.item_name_groups[self.item_name_group])) + return self.Resolved( + self.item_name_group, + item_names, + self.count, + player=world.player, + caching_enabled=world.rule_caching_enabled, + ) + + @override + def __str__(self) -> str: + count = f", count={self.count}" if self.count > 1 else "" + options = f", options={self.options}" if self.options else "" + return f"{self.__class__.__name__}({self.item_name_group}{count}{options})" + + class Resolved(Rule.Resolved): + item_name_group: str + item_names: tuple[str, ...] + count: int = 1 + @override def _evaluate(self, state: CollectionState) -> bool: # implementation based on state.has_group_unique @@ -1779,6 +1848,10 @@ def _evaluate(self, state: CollectionState) -> bool: return True return False + @override + def item_dependencies(self) -> dict[str, set[int]]: + return {item: {id(self)} for item in self.item_names} + @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: messages: list[JSONMessagePart] = [{"type": "text", "text": "Has "}] From 7c85680eee498cc1355a26837e0ba0674041445c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 19 Sep 2025 22:36:04 -0400 Subject: [PATCH 079/135] update docs on caching --- docs/rule builder.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 10f44ce711a0..cda915216061 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -149,13 +149,13 @@ To add a rule that checks if the user has enough mcguffins to goal, with a rando ```python @dataclasses.dataclass() class CanGoal(Rule["MyWorld"], game="My Game"): - def _instantiate(self, world: "MyWorld") -> "Resolved": - return self.Resolved(world.required_mcguffins, player=world.player) + def _instantiate(self, world: "MyWorld") -> Rule.Resolved: + return self.Resolved(world.required_mcguffins, player=world.player, caching_enabled=world.rule_caching_enabled) class Resolved(Rule.Resolved): goal: int - def _evaluate(self, state: "CollectionState") -> bool: + def _evaluate(self, state: CollectionState) -> bool: return state.has("McGuffin", self.player, count=self.goal) def item_dependencies(self) -> dict[str, set[int]]: @@ -230,6 +230,19 @@ class MyRule(Rule["MyWorld"], game="My Game"): The default `CanReachEntrance` rule defines this function already. +### Cache control + +By default your custom rule will work through the cache system as any other rule. There are two class attributes on the `Resolved` class you can override to change this behaviour. + +- `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=world.rule_caching_enabled` in your `_instantiate` function when creating resolved rule instances. +- 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 so they get registered with the world's caching system. + ## 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. @@ -336,7 +349,7 @@ If your logic has been done in custom JSON first, you can define a `from_dict` c ```python class BasicLogicRule(Rule, game="My Game"): @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: items = data.get("items", ()) return cls(*items) ``` @@ -351,13 +364,13 @@ To implement a custom message with a custom rule, override the `explain_json` an class MyRule(Rule, game="My Game"): class Resolved(Rule.Resolved): @override - def explain_json(self, state: "CollectionState | None" = None) -> "list[JSONMessagePart]": + 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 + color = "green" elif state is not None: start = "You are not " color = "salmon" @@ -368,7 +381,7 @@ class MyRule(Rule, game="My Game"): ] @override - def explain_str(self, state: "CollectionState | None" = None) -> str: + def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) if state.has("growth spurt", self.player): From 7d8c7f61c44c4d9fd7ae69640c4589f6b019231f Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 21 Sep 2025 17:33:14 -0400 Subject: [PATCH 080/135] fix latest astalon updates --- worlds/astalon/client.py | 2 +- worlds/astalon/items.py | 8 +++----- worlds/astalon/test/bases.py | 2 +- worlds/astalon/tracker.py | 15 ++++++++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/worlds/astalon/client.py b/worlds/astalon/client.py index 267c1582544c..5659189c2bf3 100644 --- a/worlds/astalon/client.py +++ b/worlds/astalon/client.py @@ -11,7 +11,7 @@ from MultiServer import mark_raw from NetUtils import JSONMessagePart from rule_builder import Rule -from Utils import get_intended_text +from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] from worlds.AutoWorld import World from .constants import GAME_NAME, VERSION diff --git a/worlds/astalon/items.py b/worlds/astalon/items.py index 6c8fe6214db7..f69270d6bcfb 100644 --- a/worlds/astalon/items.py +++ b/worlds/astalon/items.py @@ -2,9 +2,7 @@ from dataclasses import dataclass from enum import Enum from itertools import groupby -from typing import TYPE_CHECKING - -from typing_extensions import TypeAlias +from typing import TYPE_CHECKING, TypeAlias from BaseClasses import Item, ItemClassification @@ -463,7 +461,7 @@ class Trap(str, Enum): Switch.CAVES_CATA_3, ) -EARLY_ITEMS: set[ItemName] = set([*EARLY_WHITE_DOORS, *EARLY_BLUE_DOORS, *EARLY_SWITCHES]) +EARLY_ITEMS: set[ItemName] = {*EARLY_WHITE_DOORS, *EARLY_BLUE_DOORS, *EARLY_SWITCHES} QOL_ITEMS: tuple[ShopUpgrade, ...] = ( ShopUpgrade.KNOWLEDGE, @@ -879,7 +877,7 @@ def get_item_group(item_name: str): item_name_groups: dict[str, set[str]] = { - group.value: set(item for item in item_names) + group.value: set(item_names) for group, item_names in groupby(sorted(item_table, key=get_item_group), get_item_group) if group != "" } diff --git a/worlds/astalon/test/bases.py b/worlds/astalon/test/bases.py index 762c9dfe5276..273f4b45144b 100644 --- a/worlds/astalon/test/bases.py +++ b/worlds/astalon/test/bases.py @@ -6,4 +6,4 @@ class AstalonTestBase(WorldTestBase): game = GAME_NAME - world: AstalonWorld # type: ignore + world: AstalonWorld # pyright: ignore[reportIncompatibleVariableOverride] diff --git a/worlds/astalon/tracker.py b/worlds/astalon/tracker.py index 53f0e196e0af..6d0c52e5fead 100644 --- a/worlds/astalon/tracker.py +++ b/worlds/astalon/tracker.py @@ -8,10 +8,11 @@ from BaseClasses import CollectionState, Entrance, Location, Region from NetUtils import JSONMessagePart from Options import Option -from Utils import get_intended_text +from rule_builder import Rule +from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] +from worlds.generic.Rules import CollectionRule from .items import Character, Events -from .logic.instances import RuleInstance if TYPE_CHECKING: from worlds.AutoWorld import World @@ -76,11 +77,11 @@ def location_icon_coords(index: int | None, coords: dict[str, Any]) -> tuple[int return x, y, f"images/icons/{icon}.png" -def rule_to_json(rule: RuleInstance | None, state: CollectionState) -> list[JSONMessagePart]: - if rule: +def rule_to_json(rule: CollectionRule | None, state: CollectionState) -> list[JSONMessagePart]: + if isinstance(rule, Rule.Resolved): return [ {"type": "text", "text": " "}, - *rule.explain(state), + *rule.explain_json(state), ] return [ {"type": "text", "text": " "}, @@ -171,7 +172,7 @@ def get_logical_path(self, dest_name: str, state: CollectionState) -> list[JSONM [ {"type": "entrance_name", "text": p.name, "player": self.player}, {"type": "text", "text": "\n"}, - *rule_to_json(getattr(p.access_rule, "__self__", None), state), + *rule_to_json(p.access_rule, state), {"type": "text", "text": "\n"}, ] ) @@ -186,7 +187,7 @@ def get_logical_path(self, dest_name: str, state: CollectionState) -> list[JSONM "player": self.player, }, {"type": "text", "text": "\n"}, - *rule_to_json(getattr(goal_location.access_rule, "__self__", None), state), + *rule_to_json(goal_location.access_rule, state), ] ) From 00f32229cbd8136ae2233408623d0a0f1f5fd712 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 29 Sep 2025 01:34:58 -0400 Subject: [PATCH 081/135] start converting rules to new format --- worlds/astalon/logic/main_campaign.py | 156 +++++++++++++------------- worlds/astalon/world.py | 16 +-- 2 files changed, 83 insertions(+), 89 deletions(-) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 041ee6f44d0d..38945a58c760 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -53,41 +53,59 @@ red_off = [OptionFilter(RandomizeRedKeys, RandomizeRedKeys.option_false)] switch_off = [OptionFilter(RandomizeSwitches, RandomizeSwitches.option_false)] -can_uppies = HardLogic(True_(options=characters_off) | HasAny(Character.ARIAS, Character.BRAM, options=characters_on)) -can_extra_height = HasAny(Character.KYULI, KeyItem.BLOCK) | can_uppies -can_extra_height_gold_block = HasAny(Character.KYULI, Character.ZEEK) | can_uppies -can_combo_height = can_uppies & HasAll(KeyItem.BELL, KeyItem.BLOCK) -can_block_in_wall = HardLogic(HasAll(Character.ZEEK, KeyItem.BLOCK)) -can_crystal = ( - HasAny(Character.ALGUS, KeyItem.BLOCK, ShopUpgrade.BRAM_WHIPLASH) - | HasAll(Character.ZEEK, KeyItem.BANISH) - | HardLogic(Has(ShopUpgrade.KYULI_RAY)) -) -can_crystal_wo_whiplash = ( - HasAny(Character.ALGUS, KeyItem.BLOCK) - | HasAll(Character.ZEEK, KeyItem.BANISH) - | HardLogic(Has(ShopUpgrade.KYULI_RAY)) -) -can_crystal_wo_block = Or( - HasAny(Character.ALGUS, ShopUpgrade.BRAM_WHIPLASH), - HardLogic(Has(ShopUpgrade.KYULI_RAY)), -) -can_big_magic = HardLogic(HasAll(Character.ALGUS, KeyItem.BANISH, ShopUpgrade.ALGUS_ARCANIST)) -can_kill_ghosts = ( - HasAny(KeyItem.BANISH, KeyItem.BLOCK) - | HasAll(ShopUpgrade.ALGUS_METEOR, KeyItem.CHALICE, options=easy) - | HardLogic(Has(ShopUpgrade.ALGUS_METEOR)) -) +has_algus = True_(options=characters_off) | Has(Character.ALGUS, options=characters_on) +has_arias = True_(options=characters_off) | Has(Character.ARIAS, options=characters_on) +has_kyuli = True_(options=characters_off) | Has(Character.KYULI, options=characters_on) +has_bram = Has(Character.BRAM) +has_zeek = Has(Character.ZEEK) -otherwise_crystal = Or( - HasAny(Character.ALGUS, KeyItem.BLOCK, ShopUpgrade.BRAM_WHIPLASH), - HasAll(Character.ZEEK, KeyItem.BANISH), - HardLogic(Has(ShopUpgrade.KYULI_RAY)), - options=switch_off, -) -otherwise_bow = Has(KeyItem.BOW, options=switch_off) +has_cloak = has_algus & Has(KeyItem.CLOAK) +has_sword = has_arias & Has(KeyItem.SWORD) +has_boots = has_arias & Has(KeyItem.BOOTS) +has_claw = has_kyuli & Has(KeyItem.CLAW) +has_bow = has_kyuli & Has(KeyItem.BOW) +has_block = has_zeek & Has(KeyItem.BLOCK) +has_star = has_bram & Has(KeyItem.STAR) +has_banish = (has_algus | has_zeek) & Has(KeyItem.BANISH) +has_gauntlet = (has_arias | has_bram) & Has(KeyItem.GAUNTLET) + +has_algus_arcanist = has_algus & Has(ShopUpgrade.ALGUS_ARCANIST) +has_algus_meteor = has_algus & Has(ShopUpgrade.ALGUS_METEOR) +has_algus_shock = has_algus & Has(ShopUpgrade.ALGUS_SHOCK) +has_arias_gorgonslayer = has_arias & Has(ShopUpgrade.ARIAS_GORGONSLAYER) +has_arias_last_stand = has_arias & Has(ShopUpgrade.ARIAS_LAST_STAND) +has_arias_lionheart = has_arias & Has(ShopUpgrade.ARIAS_LIONHEART) +has_kyuli_assassin = has_kyuli & Has(ShopUpgrade.KYULI_ASSASSIN) +has_kyuli_bullseye = has_kyuli & Has(ShopUpgrade.KYULI_BULLSEYE) +has_kyuli_ray = has_kyuli & Has(ShopUpgrade.KYULI_RAY) +has_zeek_junkyard = has_zeek & Has(ShopUpgrade.ZEEK_JUNKYARD) +has_zeek_orbs = has_zeek & Has(ShopUpgrade.ZEEK_ORBS) +has_zeek_loot = has_zeek & Has(ShopUpgrade.ZEEK_LOOT) +has_bram_axe = has_bram & Has(ShopUpgrade.BRAM_AXE) +has_bram_hunter = has_bram & Has(ShopUpgrade.BRAM_HUNTER) +has_bram_whiplash = has_bram & Has(ShopUpgrade.BRAM_WHIPLASH) chalice_on_easy = HardLogic(True_()) | Has(KeyItem.CHALICE, options=easy) +# can_uppies = Macro( +# HardLogic(has_arias | has_bram), +# "Can do uppies", +# "Perform a higher jump by jumping while attacking with Arias or Bram", +# ) +can_uppies = HardLogic(has_arias | has_bram) +can_extra_height = has_kyuli | has_block | can_uppies +can_extra_height_gold_block = has_kyuli | has_zeek | can_uppies +can_combo_height = can_uppies & has_block & Has(KeyItem.BELL) +can_block_in_wall = HardLogic(has_block) +can_crystal_limited = has_algus | HardLogic(has_kyuli_ray) +can_crystal_no_whiplash = can_crystal_limited | has_block | (has_zeek & has_banish) +can_crystal_no_block = can_crystal_limited | has_bram_whiplash +can_crystal = can_crystal_no_whiplash | has_bram_whiplash +can_big_magic = HardLogic(has_algus_arcanist & has_banish) +can_kill_ghosts = has_banish | has_block | (has_algus_meteor & chalice_on_easy) + +otherwise_crystal = And(can_crystal, options=switch_off) +otherwise_bow = And(has_bow, options=switch_off) + elevator_apex = HasElevator( Elevator.APEX, options=[OptionFilter(ApexElevator, ApexElevator.option_included)], @@ -95,40 +113,27 @@ KeyItem.ASCENDANT_KEY, options=[OptionFilter(ApexElevator, ApexElevator.option_vanilla)], ) + # TODO: better implementations shop_cheap = CanReachRegion(R.GT_LEFT) shop_moderate = CanReachRegion(R.MECH_START) shop_expensive = CanReachRegion(R.ROA_START) MAIN_ENTRANCE_RULES: dict[tuple[R, R], Rule["AstalonWorld"]] = { - (R.SHOP, R.SHOP_ALGUS): Has(Character.ALGUS), - (R.SHOP, R.SHOP_ARIAS): Has(Character.ARIAS), - (R.SHOP, R.SHOP_KYULI): Has(Character.KYULI), - (R.SHOP, R.SHOP_ZEEK): Has(Character.ZEEK), - (R.SHOP, R.SHOP_BRAM): Has(Character.BRAM), + (R.SHOP, R.SHOP_ALGUS): has_algus, + (R.SHOP, R.SHOP_ARIAS): has_arias, + (R.SHOP, R.SHOP_KYULI): has_kyuli, + (R.SHOP, R.SHOP_ZEEK): has_zeek, + (R.SHOP, R.SHOP_BRAM): has_bram, (R.GT_ENTRANCE, R.GT_BESTIARY): HasBlue(BlueDoor.GT_HUNTER, otherwise=True), - (R.GT_ENTRANCE, R.GT_BABY_GORGON): And( - Has(Eye.GREEN), - Or( - Has(KeyItem.CLAW), - HardLogic( - And( - Has(Character.ZEEK), - Or(HasAll(Character.KYULI, KeyItem.BELL), Has(KeyItem.BLOCK)), - ) - ), - ), + (R.GT_ENTRANCE, R.GT_BABY_GORGON): ( + Has(Eye.GREEN) & (has_claw | HardLogic(has_zeek & ((has_kyuli & Has(KeyItem.BELL)) | has_block))) ), - (R.GT_ENTRANCE, R.GT_BOTTOM): Or( - HasSwitch(Switch.GT_2ND_ROOM), - HasWhite(WhiteDoor.GT_START, otherwise=True, options=switch_off), + (R.GT_ENTRANCE, R.GT_BOTTOM): ( + HasSwitch(Switch.GT_2ND_ROOM) | HasWhite(WhiteDoor.GT_START, otherwise=True, options=switch_off) ), (R.GT_ENTRANCE, R.GT_VOID): Has(KeyItem.VOID), - (R.GT_ENTRANCE, R.GT_GORGONHEART): Or( - HasSwitch(Switch.GT_GH_SHORTCUT), - Has(KeyItem.ICARUS), - HardLogic(Has(KeyItem.BOOTS)), - ), + (R.GT_ENTRANCE, R.GT_GORGONHEART): HasSwitch(Switch.GT_GH_SHORTCUT) | has_boots | HardLogic(Has(KeyItem.ICARUS)), (R.GT_ENTRANCE, R.GT_BOSS): HasElevator(Elevator.GT_2), (R.GT_ENTRANCE, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.GT_ENTRANCE, R.MECH_BOSS): HasElevator(Elevator.MECH_2), @@ -141,33 +146,22 @@ (R.GT_ENTRANCE, R.TR_START): HasElevator(Elevator.TR), (R.GT_BOTTOM, R.GT_VOID): Has(Eye.RED), (R.GT_BOTTOM, R.GT_GORGONHEART): HasWhite(WhiteDoor.GT_MAP, otherwise=True), - (R.GT_BOTTOM, R.GT_UPPER_PATH): Or( - HasSwitch(Crystal.GT_ROTA), - can_uppies, - And(Has(KeyItem.STAR), HasBlue(BlueDoor.GT_RING, otherwise=True)), - Has(KeyItem.BLOCK), - ), - (R.GT_BOTTOM, R.CAVES_START): Or( - Has(Character.KYULI), - HardLogic(HasAny(Character.ZEEK, KeyItem.BOOTS)), + (R.GT_BOTTOM, R.GT_UPPER_PATH): ( + HasSwitch(Crystal.GT_ROTA) + | can_uppies + | (has_star & HasBlue(BlueDoor.GT_RING, otherwise=True)) + | Has(KeyItem.BLOCK) ), + (R.GT_BOTTOM, R.CAVES_START): has_kyuli | HardLogic(has_zeek | has_boots), (R.GT_VOID, R.GT_BOTTOM): Has(Eye.RED), (R.GT_VOID, R.MECH_SNAKE): HasSwitch(Switch.MECH_SNAKE_2), (R.GT_GORGONHEART, R.GT_ORBS_DOOR): HasBlue(BlueDoor.GT_ORBS, otherwise=True), - (R.GT_GORGONHEART, R.GT_LEFT): Or( - HasSwitch(Switch.GT_CROSSES), - HasSwitch(Switch.GT_1ST_CYCLOPS, otherwise=True), - ), - (R.GT_LEFT, R.GT_GORGONHEART): Or( - HasSwitch(Switch.GT_CROSSES, otherwise=True), - HasSwitch(Switch.GT_1ST_CYCLOPS), - ), + (R.GT_GORGONHEART, R.GT_LEFT): HasSwitch(Switch.GT_CROSSES) | HasSwitch(Switch.GT_1ST_CYCLOPS, otherwise=True), + (R.GT_LEFT, R.GT_GORGONHEART): HasSwitch(Switch.GT_CROSSES, otherwise=True) | HasSwitch(Switch.GT_1ST_CYCLOPS), (R.GT_LEFT, R.GT_ORBS_HEIGHT): can_extra_height, (R.GT_LEFT, R.GT_ASCENDANT_KEY): HasBlue(BlueDoor.GT_ASCENDANT, otherwise=True), - (R.GT_LEFT, R.GT_TOP_LEFT): Or( - HasSwitch(Switch.GT_ARIAS), - HasAny(Character.ARIAS, KeyItem.CLAW), - HasAll(KeyItem.BLOCK, Character.KYULI, KeyItem.BELL), + (R.GT_LEFT, R.GT_TOP_LEFT): ( + HasSwitch(Switch.GT_ARIAS) | has_arias | has_claw | (has_block & has_kyuli & Has(KeyItem.BELL)) ), (R.GT_LEFT, R.GT_TOP_RIGHT): can_extra_height, (R.GT_TOP_LEFT, R.GT_BUTT): Or( @@ -821,9 +815,9 @@ ), (R.ROA_ABOVE_CENTAUR_R, R.ROA_DARK_EXIT): HasAll(Character.ARIAS, KeyItem.BELL), (R.ROA_ABOVE_CENTAUR_R, R.ROA_ABOVE_CENTAUR_L): HasAll(KeyItem.STAR, KeyItem.BELL), - (R.ROA_ABOVE_CENTAUR_R, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_wo_whiplash, + (R.ROA_ABOVE_CENTAUR_R, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_whiplash, (R.ROA_ABOVE_CENTAUR_L, R.ROA_ABOVE_CENTAUR_R): HasAll(KeyItem.STAR, KeyItem.BELL), - (R.ROA_ABOVE_CENTAUR_L, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_wo_block, + (R.ROA_ABOVE_CENTAUR_L, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_block, (R.ROA_BOSS_CONNECTION, R.ROA_ABOVE_CENTAUR_L): can_extra_height, (R.ROA_BOSS_CONNECTION, R.ROA_TOP_CENTAUR): Or( HasSwitch(Crystal.ROA_CENTAUR), @@ -1262,11 +1256,11 @@ L.ROA_CRYSTAL_1ST_ROOM: And(can_crystal, HasAll(Character.KYULI, KeyItem.BELL)), L.ROA_CRYSTAL_BABY_GORGON: can_crystal, L.ROA_CRYSTAL_LADDER_R: And( - can_crystal_wo_whiplash, + can_crystal_no_whiplash, Or(Has(KeyItem.BELL), HardLogic(Has(ShopUpgrade.KYULI_RAY))), ), L.ROA_CRYSTAL_LADDER_L: And( - can_crystal_wo_whiplash, + can_crystal_no_whiplash, Or(Has(KeyItem.BELL), HardLogic(Has(ShopUpgrade.KYULI_RAY))), ), L.ROA_CRYSTAL_CENTAUR: And(can_crystal, HasAll(KeyItem.BELL, Character.ARIAS)), diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 0029e4ae87f5..39d35ff97995 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -5,7 +5,7 @@ from typing_extensions import override from BaseClasses import Item, ItemClassification, MultiWorld, Region, Tutorial -from Options import OptionError, PerGameCommonOptions +from Options import OptionError from rule_builder import RuleWorldMixin from worlds.AutoWorld import WebWorld, World from worlds.LauncherComponents import ( @@ -119,15 +119,15 @@ class AstalonWorld(UTMxin, RuleWorldMixin, World): # pyright: ignore[reportUnsa on a mission to save their village from impending doom! """ - game: ClassVar[str] = GAME_NAME - web: ClassVar[WebWorld] = AstalonWebWorld() - options_dataclass: ClassVar[type[PerGameCommonOptions]] = AstalonOptions + game = GAME_NAME + web = AstalonWebWorld() + options_dataclass = AstalonOptions options: AstalonOptions # pyright: ignore[reportIncompatibleVariableOverride] - item_name_groups: ClassVar[dict[str, set[str]]] = item_name_groups - location_name_groups: ClassVar[dict[str, set[str]]] = location_name_groups - item_name_to_id: ClassVar[dict[str, int]] = item_name_to_id + item_name_groups = item_name_groups + location_name_groups = location_name_groups + item_name_to_id = item_name_to_id location_name_to_id = location_name_to_id - rule_caching_enabled: ClassVar[bool] = True + rule_caching_enabled = True starting_characters: list[Character] extra_gold_eyes: int = 0 From 660069bc41514c1c1a0241911c08709e6dfdca05 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 29 Sep 2025 02:05:16 -0400 Subject: [PATCH 082/135] fix name of attribute --- rule_builder.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 62f7a76f4e4a..0ac304dc40bf 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -20,8 +20,8 @@ class RuleWorldMixin(World): """A World mixin that provides helpers for interacting with the rule builder""" - rule_ids: dict[int, "Rule.Resolved"] - """A mapping of ids to resolved rules""" + rules_by_hash: dict[int, "Rule.Resolved"] + """A mapping of hash values to resolved rules""" rule_item_dependencies: dict[str, set[int]] """A mapping of item name to set of rule ids""" @@ -54,7 +54,7 @@ class RuleWorldMixin(World): def __init__(self, multiworld: MultiWorld, player: int) -> None: super().__init__(multiworld, player) - self.rule_ids = {} + self.rules_by_hash = {} self.rule_item_dependencies = defaultdict(set) self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) @@ -83,9 +83,9 @@ def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": def get_cached_rule(self, resolved_rule: "Rule.Resolved") -> "Rule.Resolved": """Returns a cached instance of a resolved rule based on the hash""" rule_hash = hash(resolved_rule) - if rule_hash in self.rule_ids: - return self.rule_ids[rule_hash] - self.rule_ids[rule_hash] = resolved_rule + if rule_hash in self.rules_by_hash: + return self.rules_by_hash[rule_hash] + self.rules_by_hash[rule_hash] = resolved_rule return resolved_rule def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: From 7cf03382271426d815aeb075c235e3e2183ca14c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 29 Sep 2025 02:17:40 -0400 Subject: [PATCH 083/135] make explain messages more colorful --- rule_builder.py | 70 ++++++++------------------ test/general/test_rule_builder.py | 82 +++++++++++++++---------------- 2 files changed, 61 insertions(+), 91 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 0ac304dc40bf..aece1d2cfc08 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -1000,15 +1000,11 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage if self.count > 1: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) messages.append({"type": "text", "text": "x "}) - item_message: JSONMessagePart = { - "type": "item_name", - "flags": 0b001, - "text": self.item_name, - "player": self.player, - } if state: - item_message["color"] = "green" if self(state) else "salmon" - messages.append(item_message) + color = "green" if self(state) else "salmon" + messages.append({"type": "color", "color": color, "text": self.item_name}) + else: + messages.append({"type": "item_name", "flags": 0b001, "text": self.item_name, "player": self.player}) return messages @override @@ -1103,9 +1099,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(found): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "green", "text": item}) if missing: messages.append({"type": "text", "text": "; "}) @@ -1114,9 +1108,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(missing): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "salmon", "text": item}) messages.append({"type": "text", "text": ")"}) return messages @@ -1216,9 +1208,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(found): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "green", "text": item}) if missing: messages.append({"type": "text", "text": "; "}) @@ -1227,9 +1217,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(missing): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "salmon", "text": item}) messages.append({"type": "text", "text": ")"}) return messages @@ -1323,9 +1311,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, (item, count) in enumerate(found): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "green", "text": item}) messages.append({"type": "text", "text": f" x{count}"}) if missing: messages.append({"type": "text", "text": "; "}) @@ -1335,9 +1321,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, (item, count) in enumerate(missing): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "salmon", "text": item}) messages.append({"type": "text", "text": f" x{count}"}) messages.append({"type": "text", "text": ")"}) return messages @@ -1432,9 +1416,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, (item, count) in enumerate(found): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "green", "text": item}) messages.append({"type": "text", "text": f" x{count}"}) if missing: messages.append({"type": "text", "text": "; "}) @@ -1444,9 +1426,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, (item, count) in enumerate(missing): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "salmon", "text": item}) messages.append({"type": "text", "text": f" x{count}"}) messages.append({"type": "text", "text": ")"}) return messages @@ -1550,11 +1530,12 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage found_count = state.count_from_list(self.item_names, self.player) found = [item for item in self.item_names if state.has(item, self.player)] missing = [item for item in self.item_names if item not in found] + color = "green" if found_count >= self.count else "salmon" messages = [ {"type": "text", "text": "Has "}, { "type": "color", - "color": "green" if found_count >= self.count else "salmon", + "color": color, "text": f"{found_count}/{self.count}", }, {"type": "text", "text": " items from ("}, @@ -1564,9 +1545,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(found): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "green", "text": item}) if missing: messages.append({"type": "text", "text": "; "}) @@ -1575,9 +1554,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(missing): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "salmon", "text": item}) messages.append({"type": "text", "text": ")"}) return messages @@ -1681,13 +1658,10 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage found_count = state.count_from_list_unique(self.item_names, self.player) found = [item for item in self.item_names if state.has(item, self.player)] missing = [item for item in self.item_names if item not in found] + color = "green" if found_count >= self.count else "salmon" messages = [ {"type": "text", "text": "Has "}, - { - "type": "color", - "color": "green" if found_count >= self.count else "salmon", - "text": f"{found_count}/{self.count}", - }, + {"type": "color", "color": color, "text": f"{found_count}/{self.count}"}, {"type": "text", "text": " unique items from ("}, ] if found: @@ -1695,9 +1669,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(found): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "green", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "green", "text": item}) if missing: messages.append({"type": "text", "text": "; "}) @@ -1706,9 +1678,7 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage for i, item in enumerate(missing): if i > 0: messages.append({"type": "text", "text": ", "}) - messages.append( - {"type": "item_name", "flags": 0b001, "color": "salmon", "text": item, "player": self.player} - ) + messages.append({"type": "color", "color": "salmon", "text": item}) messages.append({"type": "text", "text": ")"}) return messages diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 60b63dddf723..3dfde889f5ba 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -35,14 +35,14 @@ class ToggleOption(Toggle): - auto_display_name: ClassVar[bool] = True + auto_display_name = True class ChoiceOption(Choice): - option_first: ClassVar[int] = 0 - option_second: ClassVar[int] = 1 - option_third: ClassVar[int] = 2 - default: ClassVar[int] = 0 + option_first = 0 + option_second = 1 + option_third = 2 + default = 0 @dataclass @@ -56,30 +56,30 @@ class RuleBuilderOptions(PerGameCommonOptions): class RuleBuilderItem(Item): - game: str = GAME + game = GAME class RuleBuilderLocation(Location): - game: str = GAME + game = GAME class RuleBuilderWebWorld(WebWorld): - tutorials = [] # noqa: RUF012 # pyright: ignore[reportUnannotatedClassAttribute] + tutorials = [] # noqa: RUF012 class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] - game: ClassVar[str] = GAME - web: ClassVar[WebWorld] = RuleBuilderWebWorld() + game = GAME + web = RuleBuilderWebWorld() item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} item_name_groups: ClassVar[dict[str, set[str]]] = { "Group 1": {"Item 1", "Item 2", "Item 3"}, "Group 2": {"Item 4", "Item 5"}, } - hidden: ClassVar[bool] = True - options_dataclass: "ClassVar[type[PerGameCommonOptions]]" = RuleBuilderOptions + hidden = True + options_dataclass = RuleBuilderOptions options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] - origin_region_name: str = "Region 1" + origin_region_name = "Region 1" @override def create_item(self, name: str) -> "RuleBuilderItem": @@ -847,24 +847,24 @@ def test_explain_json_with_state_no_items(self) -> None: {"type": "text", "text": "Missing "}, {"type": "color", "color": "cyan", "text": "4"}, {"type": "text", "text": "x "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 1", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 1"}, {"type": "text", "text": " | "}, {"type": "text", "text": "Missing "}, {"type": "color", "color": "cyan", "text": "some"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Missing: "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 2", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 2"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 3", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 3"}, {"type": "text", "text": ")"}, {"type": "text", "text": " | "}, {"type": "text", "text": "Missing "}, {"type": "color", "color": "cyan", "text": "all"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Missing: "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 4", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 4"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 5", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 5"}, {"type": "text", "text": ")"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, @@ -872,10 +872,10 @@ def test_explain_json_with_state_no_items(self) -> None: {"type": "color", "color": "cyan", "text": "some"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Missing: "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 6", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 6"}, {"type": "text", "text": " x1"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 7", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 7"}, {"type": "text", "text": " x5"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, @@ -883,10 +883,10 @@ def test_explain_json_with_state_no_items(self) -> None: {"type": "color", "color": "cyan", "text": "all"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Missing: "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 8", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 8"}, {"type": "text", "text": " x2"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 9", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 9"}, {"type": "text", "text": " x3"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, @@ -894,20 +894,20 @@ def test_explain_json_with_state_no_items(self) -> None: {"type": "color", "color": "salmon", "text": "0/2"}, {"type": "text", "text": " items from ("}, {"type": "text", "text": "Missing: "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 10", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 10"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 11", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 11"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 12", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 12"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, {"type": "text", "text": "Has "}, {"type": "color", "color": "salmon", "text": "0/1"}, {"type": "text", "text": " unique items from ("}, {"type": "text", "text": "Missing: "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 13", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 13"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "salmon", "text": "Item 14", "player": 1}, + {"type": "color", "color": "salmon", "text": "Item 14"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, {"type": "text", "text": "Has "}, @@ -945,24 +945,24 @@ def test_explain_json_with_state_all_items(self) -> None: {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "4"}, {"type": "text", "text": "x "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 1", "player": 1}, + {"type": "color", "color": "green", "text": "Item 1"}, {"type": "text", "text": " | "}, {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "all"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Found: "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 2", "player": 1}, + {"type": "color", "color": "green", "text": "Item 2"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 3", "player": 1}, + {"type": "color", "color": "green", "text": "Item 3"}, {"type": "text", "text": ")"}, {"type": "text", "text": " | "}, {"type": "text", "text": "Has "}, {"type": "color", "color": "cyan", "text": "some"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Found: "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 4", "player": 1}, + {"type": "color", "color": "green", "text": "Item 4"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 5", "player": 1}, + {"type": "color", "color": "green", "text": "Item 5"}, {"type": "text", "text": ")"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, @@ -970,10 +970,10 @@ def test_explain_json_with_state_all_items(self) -> None: {"type": "color", "color": "cyan", "text": "all"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Found: "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 6", "player": 1}, + {"type": "color", "color": "green", "text": "Item 6"}, {"type": "text", "text": " x1"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 7", "player": 1}, + {"type": "color", "color": "green", "text": "Item 7"}, {"type": "text", "text": " x5"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, @@ -981,10 +981,10 @@ def test_explain_json_with_state_all_items(self) -> None: {"type": "color", "color": "cyan", "text": "some"}, {"type": "text", "text": " of ("}, {"type": "text", "text": "Found: "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 8", "player": 1}, + {"type": "color", "color": "green", "text": "Item 8"}, {"type": "text", "text": " x2"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 9", "player": 1}, + {"type": "color", "color": "green", "text": "Item 9"}, {"type": "text", "text": " x3"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, @@ -992,20 +992,20 @@ def test_explain_json_with_state_all_items(self) -> None: {"type": "color", "color": "green", "text": "30/2"}, {"type": "text", "text": " items from ("}, {"type": "text", "text": "Found: "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 10", "player": 1}, + {"type": "color", "color": "green", "text": "Item 10"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 11", "player": 1}, + {"type": "color", "color": "green", "text": "Item 11"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 12", "player": 1}, + {"type": "color", "color": "green", "text": "Item 12"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, {"type": "text", "text": "Has "}, {"type": "color", "color": "green", "text": "2/1"}, {"type": "text", "text": " unique items from ("}, {"type": "text", "text": "Found: "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 13", "player": 1}, + {"type": "color", "color": "green", "text": "Item 13"}, {"type": "text", "text": ", "}, - {"type": "item_name", "flags": 1, "color": "green", "text": "Item 14", "player": 1}, + {"type": "color", "color": "green", "text": "Item 14"}, {"type": "text", "text": ")"}, {"type": "text", "text": " & "}, {"type": "text", "text": "Has "}, From 599b22438b7a1ee9003543f453736943f297126a Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 16 Nov 2025 21:09:57 -0500 Subject: [PATCH 084/135] refactor all the rules --- worlds/astalon/logic/custom_rules.py | 257 +---- worlds/astalon/logic/main_campaign.py | 1247 ++++++++++--------------- 2 files changed, 529 insertions(+), 975 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 6a4daa0191d2..62d2b8e0d569 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -1,7 +1,7 @@ import dataclasses from collections.abc import Iterable from enum import Enum -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from typing_extensions import override @@ -13,7 +13,6 @@ from ..constants import GAME_NAME from ..items import ( BlueDoor, - Character, Crystal, Elevator, Events, @@ -22,7 +21,6 @@ ItemName, KeyItem, RedDoor, - ShopUpgrade, Switch, WhiteDoor, ) @@ -31,7 +29,6 @@ Difficulty, Goal, RandomizeBlueKeys, - RandomizeCharacters, RandomizeElevator, RandomizeRedKeys, RandomizeSwitches, @@ -43,40 +40,9 @@ from ..world import AstalonWorld -ITEM_DEPS: dict[str, tuple[str, ...]] = { - KeyItem.CLOAK.value: (Character.ALGUS.value,), - KeyItem.SWORD.value: (Character.ARIAS.value,), - KeyItem.BOOTS.value: (Character.ARIAS.value,), - KeyItem.CLAW.value: (Character.KYULI.value,), - KeyItem.BOW.value: (Character.KYULI.value,), - KeyItem.BLOCK.value: (Character.ZEEK.value,), - KeyItem.STAR.value: (Character.BRAM.value,), - KeyItem.BANISH.value: (Character.ALGUS.value, Character.ZEEK.value), - KeyItem.GAUNTLET.value: (Character.ARIAS.value, Character.BRAM.value), - ShopUpgrade.ALGUS_ARCANIST.value: (Character.ALGUS.value,), - ShopUpgrade.ALGUS_METEOR.value: (Character.ALGUS.value,), - ShopUpgrade.ALGUS_SHOCK.value: (Character.ALGUS.value,), - ShopUpgrade.ARIAS_GORGONSLAYER.value: (Character.ARIAS.value,), - ShopUpgrade.ARIAS_LAST_STAND.value: (Character.ARIAS.value,), - ShopUpgrade.ARIAS_LIONHEART.value: (Character.ARIAS.value,), - ShopUpgrade.KYULI_ASSASSIN.value: (Character.KYULI.value,), - ShopUpgrade.KYULI_BULLSEYE.value: (Character.KYULI.value,), - ShopUpgrade.KYULI_RAY.value: (Character.KYULI.value,), - ShopUpgrade.ZEEK_JUNKYARD.value: (Character.ZEEK.value,), - ShopUpgrade.ZEEK_ORBS.value: (Character.ZEEK.value,), - ShopUpgrade.ZEEK_LOOT.value: (Character.ZEEK.value,), - ShopUpgrade.BRAM_AXE.value: (Character.BRAM.value,), - ShopUpgrade.BRAM_HUNTER.value: (Character.BRAM.value,), - ShopUpgrade.BRAM_WHIPLASH.value: (Character.BRAM.value,), -} - -VANILLA_CHARACTERS: frozenset[str] = frozenset((Character.ALGUS.value, Character.ARIAS.value, Character.KYULI.value)) - -characters_off = [rule_builder.OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)] -characters_on = [rule_builder.OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")] - - -def as_str(value: Enum | str) -> str: +def as_str(value: Enum | str | None) -> str: + if value is None: + return "" return value.value if isinstance(value, Enum) else value @@ -85,62 +51,20 @@ class Has(rule_builder.Has["AstalonWorld"], game=GAME_NAME): @override def __init__( self, - item_name: ItemName | Events | str, + item_name: ItemName | Events, count: int = 1, *, options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: super().__init__(as_str(item_name), count, options=options) - @override - def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: - default = self.Resolved( - self.item_name, - self.count, - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - - if self.item_name in VANILLA_CHARACTERS: - if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: - return world.true_rule - return default - - if deps := ITEM_DEPS.get(self.item_name): - if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla and ( - len(deps) > 1 or (len(deps) == 1 and deps[0] in VANILLA_CHARACTERS) - ): - return default - if len(deps) == 1: - return HasAll.Resolved( - (deps[0], self.item_name), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - return rule_builder.Or.Resolved( - tuple( - world.get_cached_rule( - HasAll.Resolved( - (d, self.item_name), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - ) - for d in deps - ), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - - return default - @dataclasses.dataclass(init=False) class HasAll(rule_builder.HasAll["AstalonWorld"], game=GAME_NAME): @override def __init__( self, - *item_names: ItemName | Events | str, + *item_names: ItemName | Events, options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: names = [as_str(name) for name in item_names] @@ -149,87 +73,13 @@ def __init__( super().__init__(*names, options=options) - @override - def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: - if len(self.item_names) == 0: - return world.true_rule - if len(self.item_names) == 1: - return Has(self.item_names[0]).resolve(world) - - new_clauses: list[rule_builder.Rule.Resolved] = [] - new_items: list[str] = [] - for item in self.item_names: - if ( - item in VANILLA_CHARACTERS - and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla - ): - continue - deps = ITEM_DEPS.get(item, []) - if not deps: - new_items.append(item) - continue - - if len(deps) > 1: - if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: - new_items.append(item) - else: - new_clauses.append( - rule_builder.Or.Resolved( - tuple( - world.get_cached_rule( - HasAll.Resolved( - (d, item), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - ) - for d in deps - ), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - ) - continue - - if ( - len(deps) == 1 - and deps[0] not in self.item_names - and not ( - deps[0] in VANILLA_CHARACTERS - and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla - ) - ): - new_items.append(deps[0]) - - new_items.append(item) - - if len(new_clauses) == 0 and len(new_items) == 0: - return world.true_rule - if len(new_items) == 1: - new_clauses.append( - Has.Resolved(new_items[0], player=world.player, caching_enabled=world.rule_caching_enabled) - ) - elif len(new_items) > 1: - new_clauses.append( - HasAll.Resolved(tuple(new_items), player=world.player, caching_enabled=world.rule_caching_enabled) - ) - if len(new_clauses) == 0: - return rule_builder.False_.Resolved(player=world.player, caching_enabled=world.rule_caching_enabled) - if len(new_clauses) == 1: - return new_clauses[0] - return rule_builder.And.Resolved( - tuple(world.get_cached_rule(c) for c in new_clauses), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - @dataclasses.dataclass(init=False) class HasAny(rule_builder.HasAny["AstalonWorld"], game=GAME_NAME): @override def __init__( self, - *item_names: ItemName | Events | str, + *item_names: ItemName | Events, options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: names = [as_str(name) for name in item_names] @@ -238,90 +88,14 @@ def __init__( super().__init__(*names, options=options) - @override - def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: - if len(self.item_names) == 0: - return world.true_rule - if len(self.item_names) == 1: - return Has(self.item_names[0]).resolve(world) - - new_clauses: list[rule_builder.Rule.Resolved] = [] - new_items: list[str] = [] - for item in self.item_names: - if ( - item in VANILLA_CHARACTERS - and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla - ): - return world.true_rule - - deps = ITEM_DEPS.get(item, []) - if not deps: - new_items.append(item) - continue - - if len(deps) > 1: - if world.options.randomize_characters.value == RandomizeCharacters.option_vanilla: - new_items.append(item) - else: - new_clauses.append( - rule_builder.Or.Resolved( - tuple( - world.get_cached_rule( - HasAll.Resolved( - (d, item), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - ) - for d in deps - ), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - ) - continue - - if ( - len(deps) == 1 - and deps[0] not in self.item_names - and not ( - deps[0] in VANILLA_CHARACTERS - and world.options.randomize_characters.value == RandomizeCharacters.option_vanilla - ) - ): - new_clauses.append( - HasAll.Resolved((deps[0], item), player=world.player, caching_enabled=world.rule_caching_enabled) - ) - else: - new_items.append(item) - - if len(new_items) == 1: - new_clauses.append( - Has.Resolved(new_items[0], player=world.player, caching_enabled=world.rule_caching_enabled) - ) - elif len(new_items) > 1: - new_clauses.append( - HasAny.Resolved(tuple(new_items), player=world.player, caching_enabled=world.rule_caching_enabled) - ) - - if len(new_clauses) == 0: - return rule_builder.False_.Resolved(player=world.player, caching_enabled=world.rule_caching_enabled) - if len(new_clauses) == 1: - return new_clauses[0] - return rule_builder.Or.Resolved( - tuple(world.get_cached_rule(c) for c in new_clauses), - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) - @dataclasses.dataclass(init=False) class CanReachLocation(rule_builder.CanReachLocation["AstalonWorld"], game=GAME_NAME): @override def __init__( self, - location_name: LocationName | str, - parent_region_name: RegionName | str = "", + location_name: LocationName, + parent_region_name: RegionName | None = None, skip_indirect_connection: bool = False, *, options: Iterable[rule_builder.OptionFilter[Any]] = (), @@ -334,7 +108,7 @@ class CanReachRegion(rule_builder.CanReachRegion["AstalonWorld"], game=GAME_NAME @override def __init__( self, - region_name: RegionName | str, + region_name: RegionName, *, options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: @@ -346,8 +120,8 @@ class CanReachEntrance(rule_builder.CanReachEntrance["AstalonWorld"], game=GAME_ @override def __init__( self, - from_region: RegionName | str, - to_region: RegionName | str, + from_region: RegionName, + to_region: RegionName, *, options: Iterable[rule_builder.OptionFilter[Any]] = (), ) -> None: @@ -362,10 +136,11 @@ class ToggleRule(HasAll, game=GAME_NAME): @override def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: - if len(self.item_names) == 1: - rule = Has(self.item_names[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) + items = tuple(cast(ItemName | Events, item) for item in self.item_names) + if len(items) == 1: + rule = Has(items[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) else: - rule = HasAll(*self.item_names, options=[rule_builder.OptionFilter(self.option_cls, 1)]) + rule = HasAll(*items, options=[rule_builder.OptionFilter(self.option_cls, 1)]) if self.otherwise: return rule_builder.Or( diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 38945a58c760..726ba5682a9f 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -33,7 +33,6 @@ HardLogic, Has, HasAll, - HasAny, HasBlue, HasElevator, HasGoal, @@ -147,10 +146,7 @@ (R.GT_BOTTOM, R.GT_VOID): Has(Eye.RED), (R.GT_BOTTOM, R.GT_GORGONHEART): HasWhite(WhiteDoor.GT_MAP, otherwise=True), (R.GT_BOTTOM, R.GT_UPPER_PATH): ( - HasSwitch(Crystal.GT_ROTA) - | can_uppies - | (has_star & HasBlue(BlueDoor.GT_RING, otherwise=True)) - | Has(KeyItem.BLOCK) + HasSwitch(Crystal.GT_ROTA) | can_uppies | (has_star & HasBlue(BlueDoor.GT_RING, otherwise=True)) | has_block ), (R.GT_BOTTOM, R.CAVES_START): has_kyuli | HardLogic(has_zeek | has_boots), (R.GT_VOID, R.GT_BOTTOM): Has(Eye.RED), @@ -164,23 +160,18 @@ HasSwitch(Switch.GT_ARIAS) | has_arias | has_claw | (has_block & has_kyuli & Has(KeyItem.BELL)) ), (R.GT_LEFT, R.GT_TOP_RIGHT): can_extra_height, - (R.GT_TOP_LEFT, R.GT_BUTT): Or( - HasSwitch(Switch.GT_BUTT_ACCESS), - CanReachRegion(R.GT_SPIKE_TUNNEL_SWITCH, options=switch_off), + (R.GT_TOP_LEFT, R.GT_BUTT): ( + HasSwitch(Switch.GT_BUTT_ACCESS) | CanReachRegion(R.GT_SPIKE_TUNNEL_SWITCH, options=switch_off) ), - (R.GT_TOP_RIGHT, R.GT_SPIKE_TUNNEL): Or( - HasSwitch(Switch.GT_SPIKE_TUNNEL), - CanReachRegion(R.GT_TOP_LEFT, options=switch_off), + (R.GT_TOP_RIGHT, R.GT_SPIKE_TUNNEL): ( + HasSwitch(Switch.GT_SPIKE_TUNNEL) | CanReachRegion(R.GT_TOP_LEFT, options=switch_off) ), (R.GT_SPIKE_TUNNEL, R.GT_TOP_RIGHT): HasSwitch(Switch.GT_SPIKE_TUNNEL), (R.GT_SPIKE_TUNNEL, R.GT_SPIKE_TUNNEL_SWITCH): can_extra_height, - (R.GT_SPIKE_TUNNEL_SWITCH, R.GT_BUTT): Or( - HardLogic(Has(KeyItem.STAR)), - HasAll(KeyItem.STAR, KeyItem.BELL, options=easy), - ), + (R.GT_SPIKE_TUNNEL_SWITCH, R.GT_BUTT): HardLogic(has_star) | And(has_star & Has(KeyItem.BELL), options=easy), (R.GT_BUTT, R.GT_TOP_LEFT): HasSwitch(Switch.GT_BUTT_ACCESS), - (R.GT_BUTT, R.GT_SPIKE_TUNNEL_SWITCH): Has(KeyItem.STAR), - (R.GT_BUTT, R.GT_BOSS): Or(HasWhite(WhiteDoor.GT_TAUROS), CanReachRegion(R.GT_TOP_RIGHT, options=white_off)), + (R.GT_BUTT, R.GT_SPIKE_TUNNEL_SWITCH): has_star, + (R.GT_BUTT, R.GT_BOSS): HasWhite(WhiteDoor.GT_TAUROS) | CanReachRegion(R.GT_TOP_RIGHT, options=white_off), (R.GT_BOSS, R.GT_BUTT): HasWhite(WhiteDoor.GT_TAUROS), (R.GT_BOSS, R.MECH_START): Has(Eye.RED), (R.GT_BOSS, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), @@ -192,75 +183,65 @@ (R.GT_BOSS, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), (R.GT_BOSS, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.GT_BOSS, R.TR_START): HasElevator(Elevator.TR), - (R.GT_UPPER_ARIAS, R.GT_OLD_MAN_FORK): Or( - HasSwitch(Crystal.GT_LADDER), - CanReachRegion(R.GT_LADDER_SWITCH, options=switch_off), + (R.GT_UPPER_ARIAS, R.GT_OLD_MAN_FORK): ( + HasSwitch(Crystal.GT_LADDER) | CanReachRegion(R.GT_LADDER_SWITCH, options=switch_off) ), - (R.GT_UPPER_ARIAS, R.MECH_SWORD_CONNECTION): Or( - Has(Character.ARIAS), - HasSwitch(Switch.GT_UPPER_ARIAS), - CanReachRegion(R.GT_ARIAS_SWORD_SWITCH, options=switch_off), + (R.GT_UPPER_ARIAS, R.MECH_SWORD_CONNECTION): ( + has_arias | HasSwitch(Switch.GT_UPPER_ARIAS) | CanReachRegion(R.GT_ARIAS_SWORD_SWITCH, options=switch_off) ), - (R.GT_OLD_MAN_FORK, R.GT_UPPER_ARIAS): Or( - HasSwitch(Crystal.GT_LADDER), - CanReachRegion(R.GT_LADDER_SWITCH, options=switch_off), + (R.GT_OLD_MAN_FORK, R.GT_UPPER_ARIAS): ( + HasSwitch(Crystal.GT_LADDER) | CanReachRegion(R.GT_LADDER_SWITCH, options=switch_off) ), (R.GT_OLD_MAN_FORK, R.GT_SWORD_FORK): HasBlue(BlueDoor.GT_SWORD, otherwise=True), - (R.GT_OLD_MAN_FORK, R.GT_OLD_MAN): Or( - Has(KeyItem.CLAW), + (R.GT_OLD_MAN_FORK, R.GT_OLD_MAN): ( # TODO: you don't need both switches, revisit when adding old man - HasSwitch(Crystal.GT_OLD_MAN_1, Crystal.GT_OLD_MAN_2), - otherwise_crystal, + has_claw | HasSwitch(Crystal.GT_OLD_MAN_1, Crystal.GT_OLD_MAN_2) | otherwise_crystal ), (R.GT_SWORD_FORK, R.GT_SWORD): HasSwitch(Switch.GT_SWORD_ACCESS, otherwise=True), - (R.GT_SWORD_FORK, R.GT_ARIAS_SWORD_SWITCH): Or(Has(KeyItem.SWORD), HasAll(KeyItem.BOW, KeyItem.BELL)), + (R.GT_SWORD_FORK, R.GT_ARIAS_SWORD_SWITCH): has_sword | (has_bow & Has(KeyItem.BELL)), (R.GT_UPPER_PATH, R.GT_UPPER_PATH_CONNECTION): HasSwitch(Switch.GT_UPPER_PATH_ACCESS), (R.GT_UPPER_PATH_CONNECTION, R.GT_UPPER_PATH): HasSwitch(Switch.GT_UPPER_PATH_ACCESS, otherwise=True), (R.GT_UPPER_PATH_CONNECTION, R.MECH_SWORD_CONNECTION): HasSwitch(Switch.MECH_TO_UPPER_GT), (R.GT_UPPER_PATH_CONNECTION, R.MECH_BOTTOM_CAMPFIRE): HasSwitch(Switch.MECH_TO_UPPER_GT), - (R.MECH_START, R.GT_LADDER_SWITCH): And(Has(Eye.RED), can_crystal), - (R.MECH_START, R.MECH_BK): And(HasBlue(BlueDoor.MECH_SHORTCUT, otherwise=True), can_extra_height), - (R.MECH_START, R.MECH_WATCHER): And( - Or(HasSwitch(Switch.MECH_CANNON), otherwise_crystal), - Or( - HasWhite(WhiteDoor.MECH_2ND), - And( - CanReachRegion(R.MECH_SWORD_CONNECTION), - HasSwitch(Switch.MECH_LOWER_KEY, otherwise=True), + (R.MECH_START, R.GT_LADDER_SWITCH): Has(Eye.RED) & can_crystal, + (R.MECH_START, R.MECH_BK): HasBlue(BlueDoor.MECH_SHORTCUT, otherwise=True) & can_extra_height, + (R.MECH_START, R.MECH_WATCHER): ( + (HasSwitch(Switch.MECH_CANNON) | otherwise_crystal) + & ( + HasWhite(WhiteDoor.MECH_2ND) + | And( + CanReachRegion(R.MECH_SWORD_CONNECTION) & HasSwitch(Switch.MECH_LOWER_KEY, otherwise=True), options=white_off, - ), - ), + ) + ) ), - (R.MECH_START, R.MECH_LINUS): Or(HasSwitch(Crystal.MECH_LINUS), otherwise_crystal), + (R.MECH_START, R.MECH_LINUS): HasSwitch(Crystal.MECH_LINUS) | otherwise_crystal, (R.MECH_START, R.MECH_LOWER_VOID): HasBlue(BlueDoor.MECH_RED, otherwise=True), (R.MECH_START, R.MECH_SACRIFICE): can_extra_height, (R.MECH_START, R.GT_BOSS): Has(Eye.RED), - (R.MECH_LINUS, R.MECH_START): Or(HasSwitch(Crystal.MECH_LINUS), otherwise_crystal), + (R.MECH_LINUS, R.MECH_START): HasSwitch(Crystal.MECH_LINUS) | otherwise_crystal, (R.MECH_LINUS, R.MECH_SWORD_CONNECTION): HasSwitch(Switch.MECH_LINUS, otherwise=True), - (R.MECH_SWORD_CONNECTION, R.MECH_BOOTS_CONNECTION): And( - HasBlue(BlueDoor.MECH_BOOTS, otherwise=True), - Or( - HasSwitch(Crystal.MECH_LOWER), - otherwise_crystal, - HasAny(KeyItem.CLAW, KeyItem.CLOAK), - HasAll(Character.KYULI, KeyItem.ICARUS), - HardLogic(Has(KeyItem.BOOTS)), - ), + (R.MECH_SWORD_CONNECTION, R.MECH_BOOTS_CONNECTION): ( + HasBlue(BlueDoor.MECH_BOOTS, otherwise=True) + & ( + HasSwitch(Crystal.MECH_LOWER) + | otherwise_crystal + | has_claw + | has_cloak + | (has_kyuli & Has(KeyItem.ICARUS)) + | HardLogic(has_boots) + ) ), (R.MECH_SWORD_CONNECTION, R.GT_UPPER_PATH_CONNECTION): HasSwitch(Switch.MECH_TO_UPPER_GT), - (R.MECH_SWORD_CONNECTION, R.MECH_LOWER_ARIAS): Has(Character.ARIAS), + (R.MECH_SWORD_CONNECTION, R.MECH_LOWER_ARIAS): has_arias, (R.MECH_SWORD_CONNECTION, R.MECH_BOTTOM_CAMPFIRE): HasSwitch(Switch.MECH_TO_UPPER_GT), (R.MECH_SWORD_CONNECTION, R.MECH_LINUS): HasSwitch(Switch.MECH_LINUS), - (R.MECH_SWORD_CONNECTION, R.GT_UPPER_ARIAS): Or(Has(Character.ARIAS), HasSwitch(Switch.GT_UPPER_ARIAS)), + (R.MECH_SWORD_CONNECTION, R.GT_UPPER_ARIAS): has_arias | HasSwitch(Switch.GT_UPPER_ARIAS), (R.MECH_BOOTS_CONNECTION, R.MECH_BOTTOM_CAMPFIRE): HasBlue(BlueDoor.MECH_VOID, otherwise=True), - (R.MECH_BOOTS_CONNECTION, R.MECH_BOOTS_LOWER): Or( - HasSwitch(Switch.MECH_BOOTS), - HasAny(Eye.RED, KeyItem.STAR, options=switch_off), - ), - (R.MECH_BOOTS_LOWER, R.MECH_BOOTS_UPPER): Or( - HasSwitch(Switch.MECH_BOOTS_LOWER, otherwise=True), - can_extra_height, + (R.MECH_BOOTS_CONNECTION, R.MECH_BOOTS_LOWER): ( + HasSwitch(Switch.MECH_BOOTS) | Or(Has(Eye.RED) | has_star, options=switch_off) ), + (R.MECH_BOOTS_LOWER, R.MECH_BOOTS_UPPER): HasSwitch(Switch.MECH_BOOTS_LOWER, otherwise=True) | can_extra_height, (R.MECH_BOTTOM_CAMPFIRE, R.GT_UPPER_PATH_CONNECTION): HasSwitch(Switch.MECH_TO_UPPER_GT, otherwise=True), (R.MECH_BOTTOM_CAMPFIRE, R.MECH_BOOTS_CONNECTION): HasBlue(BlueDoor.MECH_VOID, otherwise=True), (R.MECH_BOTTOM_CAMPFIRE, R.MECH_SNAKE): HasSwitch(Switch.MECH_SNAKE_1, otherwise=True), @@ -270,173 +251,126 @@ (R.MECH_LOWER_VOID, R.MECH_START): HasBlue(BlueDoor.MECH_RED, otherwise=True), (R.MECH_LOWER_VOID, R.MECH_UPPER_VOID): Has(KeyItem.VOID), (R.MECH_LOWER_VOID, R.HOTP_MECH_VOID_CONNECTION): Has(Eye.BLUE), - (R.MECH_WATCHER, R.MECH_START): And( - HasSwitch(Switch.MECH_CANNON), - HasWhite(WhiteDoor.MECH_2ND), - ), - (R.MECH_WATCHER, R.MECH_ROOTS): Or(Has(KeyItem.CLAW), HasSwitch(Switch.MECH_WATCHER, otherwise=True)), - (R.MECH_ROOTS, R.MECH_ZEEK_CONNECTION): HasAll(KeyItem.CLAW, KeyItem.BLOCK, KeyItem.BELL), + (R.MECH_WATCHER, R.MECH_START): HasSwitch(Switch.MECH_CANNON) & HasWhite(WhiteDoor.MECH_2ND), + (R.MECH_WATCHER, R.MECH_ROOTS): has_claw | HasSwitch(Switch.MECH_WATCHER, otherwise=True), + (R.MECH_ROOTS, R.MECH_ZEEK_CONNECTION): has_claw & has_block & Has(KeyItem.BELL), (R.MECH_ROOTS, R.MECH_MUSIC): HasBlue(BlueDoor.MECH_MUSIC, otherwise=True), - (R.MECH_BK, R.MECH_START): And( - HasBlue(BlueDoor.MECH_SHORTCUT, otherwise=True), - Or(Has(Character.KYULI), can_combo_height), - ), - (R.MECH_BK, R.MECH_AFTER_BK): Or(HasSwitch(Crystal.MECH_BK), otherwise_crystal), - (R.MECH_BK, R.MECH_ROOTS): Or(HasSwitch(Crystal.MECH_CAMPFIRE), otherwise_crystal), - (R.MECH_BK, R.MECH_TRIPLE_SWITCHES): And( - can_crystal, - HasSwitch( + (R.MECH_BK, R.MECH_START): HasBlue(BlueDoor.MECH_SHORTCUT, otherwise=True) & (has_kyuli | can_combo_height), + (R.MECH_BK, R.MECH_AFTER_BK): HasSwitch(Crystal.MECH_BK) | otherwise_crystal, + (R.MECH_BK, R.MECH_ROOTS): HasSwitch(Crystal.MECH_CAMPFIRE) | otherwise_crystal, + (R.MECH_BK, R.MECH_TRIPLE_SWITCHES): ( + can_crystal + & HasSwitch( Crystal.MECH_BK, Switch.MECH_TO_BOSS_1, Crystal.MECH_TRIPLE_1, Crystal.MECH_TRIPLE_2, Crystal.MECH_TRIPLE_3, - ), - Or(HasWhite(WhiteDoor.MECH_BK), HasSwitch(Switch.MECH_CHAINS)), - ), - (R.MECH_AFTER_BK, R.MECH_CHAINS_CANDLE): Or( - Has(KeyItem.CLAW), - HasWhite(WhiteDoor.MECH_BK, otherwise=True), + ) + & (HasWhite(WhiteDoor.MECH_BK) | HasSwitch(Switch.MECH_CHAINS)) ), + (R.MECH_AFTER_BK, R.MECH_CHAINS_CANDLE): has_claw | HasWhite(WhiteDoor.MECH_BK, otherwise=True), (R.MECH_AFTER_BK, R.MECH_CHAINS): HasSwitch(Switch.MECH_CHAINS), - (R.MECH_AFTER_BK, R.MECH_BK): Or( - HasSwitch(Crystal.MECH_BK), - HardLogic(Has(ShopUpgrade.KYULI_RAY), options=switch_off), - ), - (R.MECH_AFTER_BK, R.HOTP_EPIMETHEUS): Has(KeyItem.CLAW), - (R.MECH_CHAINS, R.MECH_CHAINS_CANDLE): Has(KeyItem.CLAW), - (R.MECH_CHAINS, R.MECH_ARIAS_EYEBALL): Has(Character.ARIAS), + (R.MECH_AFTER_BK, R.MECH_BK): HasSwitch(Crystal.MECH_BK) | HardLogic(has_kyuli_ray, options=switch_off), + (R.MECH_AFTER_BK, R.HOTP_EPIMETHEUS): has_claw, + (R.MECH_CHAINS, R.MECH_CHAINS_CANDLE): has_claw, + (R.MECH_CHAINS, R.MECH_ARIAS_EYEBALL): has_arias, (R.MECH_CHAINS, R.MECH_SPLIT_PATH): HasSwitch(Switch.MECH_SPLIT_PATH, otherwise=True), (R.MECH_CHAINS, R.MECH_BOSS_SWITCHES): HasSwitch(Switch.MECH_TO_BOSS_1), - (R.MECH_CHAINS, R.MECH_BOSS_CONNECTION): Or( - Has(KeyItem.CLAW), - HasSwitch(Switch.MECH_TO_BOSS_2), - HasSwitch(Crystal.MECH_TO_BOSS_3), - HardLogic(Or(can_big_magic, Has(ShopUpgrade.KYULI_RAY)), options=switch_off), + (R.MECH_CHAINS, R.MECH_BOSS_CONNECTION): ( + has_claw + | HasSwitch(Switch.MECH_TO_BOSS_2) + | HasSwitch(Crystal.MECH_TO_BOSS_3) + | HardLogic(can_big_magic | has_kyuli_ray, options=switch_off) ), (R.MECH_CHAINS, R.MECH_AFTER_BK): HasSwitch(Switch.MECH_CHAINS, otherwise=True), - (R.MECH_ARIAS_EYEBALL, R.MECH_ZEEK_CONNECTION): Or( - HasSwitch(Switch.MECH_ARIAS, otherwise=True), - HasAll(KeyItem.STAR, KeyItem.BELL), + (R.MECH_ARIAS_EYEBALL, R.MECH_ZEEK_CONNECTION): ( + HasSwitch(Switch.MECH_ARIAS, otherwise=True) | (has_star & Has(KeyItem.BELL)) ), - (R.MECH_ARIAS_EYEBALL, R.MECH_CHAINS): And( - HasAll(Character.ARIAS, KeyItem.BELL), - HasAny(Character.ALGUS, ShopUpgrade.BRAM_WHIPLASH), - Or(HasSwitch(Switch.MECH_ARIAS), Has(KeyItem.STAR)), - ), - (R.MECH_ZEEK_CONNECTION, R.MECH_ARIAS_EYEBALL): Or( - HasSwitch(Switch.MECH_ARIAS), - HasAll(KeyItem.STAR, Character.ARIAS), + (R.MECH_ARIAS_EYEBALL, R.MECH_CHAINS): ( + has_arias & Has(KeyItem.BELL) & (has_algus | has_bram_whiplash) & (HasSwitch(Switch.MECH_ARIAS) | has_star) ), + (R.MECH_ZEEK_CONNECTION, R.MECH_ARIAS_EYEBALL): HasSwitch(Switch.MECH_ARIAS) | (has_star & has_arias), (R.MECH_ZEEK_CONNECTION, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), (R.MECH_ZEEK_CONNECTION, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.MECH_ZEEK_CONNECTION, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), (R.MECH_ZEEK_CONNECTION, R.TR_START): HasElevator(Elevator.TR), (R.MECH_ZEEK_CONNECTION, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.MECH_ZEEK_CONNECTION, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), - (R.MECH_ZEEK_CONNECTION, R.MECH_ZEEK): Or( - HasRed(RedDoor.ZEEK), - CanReachRegion(R.MECH_LOWER_VOID, options=red_off), - ), + (R.MECH_ZEEK_CONNECTION, R.MECH_ZEEK): HasRed(RedDoor.ZEEK) | CanReachRegion(R.MECH_LOWER_VOID, options=red_off), (R.MECH_ZEEK_CONNECTION, R.APEX): elevator_apex, (R.MECH_ZEEK_CONNECTION, R.GT_BOSS): HasElevator(Elevator.GT_2), (R.MECH_ZEEK_CONNECTION, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.MECH_SPLIT_PATH, R.MECH_CHAINS): HasSwitch(Switch.MECH_SPLIT_PATH), (R.MECH_RIGHT, R.MECH_TRIPLE_SWITCHES): HardLogic( - And( - HasSwitch( - Switch.MECH_SPLIT_PATH, - Switch.MECH_TO_BOSS_1, - Crystal.MECH_TRIPLE_1, - Crystal.MECH_TRIPLE_2, - Crystal.MECH_TRIPLE_3, - ), - HasAll(KeyItem.STAR, ShopUpgrade.BRAM_WHIPLASH), + HasSwitch( + Switch.MECH_SPLIT_PATH, + Switch.MECH_TO_BOSS_1, + Crystal.MECH_TRIPLE_1, + Crystal.MECH_TRIPLE_2, + Crystal.MECH_TRIPLE_3, ) + & has_star + & has_bram_whiplash ), - (R.MECH_RIGHT, R.MECH_OLD_MAN): Or( - HasSwitch(Crystal.MECH_OLD_MAN), - otherwise_crystal, - HasAll(Character.KYULI, KeyItem.BLOCK, KeyItem.BELL), + (R.MECH_RIGHT, R.MECH_OLD_MAN): ( + HasSwitch(Crystal.MECH_OLD_MAN) | otherwise_crystal | (has_kyuli & has_block & Has(KeyItem.BELL)) ), - (R.MECH_RIGHT, R.MECH_SPLIT_PATH): Has(KeyItem.STAR), - (R.MECH_RIGHT, R.MECH_BELOW_POTS): Or( - HasWhite(WhiteDoor.MECH_ARENA, otherwise=True), - HasSwitch(Switch.MECH_EYEBALL), + (R.MECH_RIGHT, R.MECH_SPLIT_PATH): has_star, + (R.MECH_RIGHT, R.MECH_BELOW_POTS): ( + HasWhite(WhiteDoor.MECH_ARENA, otherwise=True) | HasSwitch(Switch.MECH_EYEBALL) ), - (R.MECH_RIGHT, R.MECH_UPPER_VOID): Or( - HasSwitch(Switch.MECH_UPPER_VOID), - And(Has(KeyItem.CLAW), HasSwitch(Switch.MECH_UPPER_VOID_DROP, otherwise=True)), + (R.MECH_RIGHT, R.MECH_UPPER_VOID): ( + HasSwitch(Switch.MECH_UPPER_VOID) | (has_claw & HasSwitch(Switch.MECH_UPPER_VOID_DROP, otherwise=True)) ), (R.MECH_UPPER_VOID, R.MECH_RIGHT): HasSwitch(Switch.MECH_UPPER_VOID, otherwise=True), (R.MECH_UPPER_VOID, R.MECH_LOWER_VOID): Has(KeyItem.VOID), - (R.MECH_BELOW_POTS, R.MECH_RIGHT): Or( - HasWhite(WhiteDoor.MECH_ARENA), - HasSwitch(Switch.MECH_EYEBALL, otherwise=True), + (R.MECH_BELOW_POTS, R.MECH_RIGHT): ( + HasWhite(WhiteDoor.MECH_ARENA) | HasSwitch(Switch.MECH_EYEBALL, otherwise=True) ), (R.MECH_BELOW_POTS, R.MECH_POTS): HasSwitch(Switch.MECH_POTS, otherwise=True), (R.MECH_POTS, R.MECH_BELOW_POTS): HasSwitch(Switch.MECH_POTS), (R.MECH_POTS, R.MECH_TOP): HasSwitch(Switch.MECH_POTS, otherwise=True), (R.MECH_TOP, R.MECH_POTS): HasSwitch(Switch.MECH_POTS), - (R.MECH_TOP, R.MECH_TP_CONNECTION): Or( - Has(KeyItem.CLAW), - Or( - HasWhite(WhiteDoor.MECH_TOP), - And(can_extra_height, Or(HasSwitch(Crystal.MECH_TOP), otherwise_crystal), options=white_off), - ), - ), - (R.MECH_TOP, R.MECH_CD_ACCESS): And( - Has(Eye.BLUE), - HasBlue(BlueDoor.MECH_CD, otherwise=True), - Or( - HasSwitch(Crystal.MECH_TO_CD), - otherwise_crystal, - HasAll(Character.KYULI, KeyItem.BLOCK, KeyItem.BELL), - ), + (R.MECH_TOP, R.MECH_TP_CONNECTION): ( + has_claw + | HasWhite(WhiteDoor.MECH_TOP) + | And(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) + ), + (R.MECH_TOP, R.MECH_CD_ACCESS): ( + Has(Eye.BLUE) + & HasBlue(BlueDoor.MECH_CD, otherwise=True) + & (HasSwitch(Crystal.MECH_TO_CD) | otherwise_crystal | (has_kyuli & has_block & Has(KeyItem.BELL))) ), (R.MECH_CD_ACCESS, R.CD_START): Has(KeyItem.CYCLOPS), - (R.MECH_TOP, R.MECH_TRIPLE_SWITCHES): And( - can_crystal, - Or(HasSwitch(Switch.MECH_ARIAS_CYCLOPS), Has(Character.ARIAS, options=switch_off)), - Or( - HasWhite(WhiteDoor.MECH_TOP), - And(can_extra_height, Or(HasSwitch(Crystal.MECH_TOP), otherwise_crystal), options=white_off), - HasAll(KeyItem.CLAW, KeyItem.BELL), - ), - ), - (R.MECH_TP_CONNECTION, R.HOTP_FALL_BOTTOM): Or(Has(KeyItem.CLAW), HasSwitch(Switch.MECH_MAZE_BACKDOOR)), - (R.MECH_TP_CONNECTION, R.MECH_TOP): Or(Has(KeyItem.CLAW), HasWhite(WhiteDoor.MECH_TOP)), - (R.MECH_TP_CONNECTION, R.MECH_CHARACTER_SWAPS): Or( - And( - Has(Character.ARIAS), - Or(HasWhite(WhiteDoor.MECH_TOP, otherwise=True), Has(KeyItem.BELL)), - ), - HasSwitch(Switch.MECH_ARIAS_CYCLOPS), - ), - (R.MECH_CHARACTER_SWAPS, R.MECH_CLOAK_CONNECTION): And( - Or( - HasSwitch(Crystal.MECH_TRIPLE_1, Crystal.MECH_TRIPLE_2, Crystal.MECH_TRIPLE_3), - otherwise_crystal, - ), - can_extra_height, - ), - (R.MECH_CHARACTER_SWAPS, R.MECH_TP_CONNECTION): Or( - Has(Character.ARIAS), - HasSwitch(Switch.MECH_ARIAS_CYCLOPS, otherwise=True), + (R.MECH_TOP, R.MECH_TRIPLE_SWITCHES): ( + can_crystal + & (HasSwitch(Switch.MECH_ARIAS_CYCLOPS) | And(has_arias, options=switch_off)) + & ( + HasWhite(WhiteDoor.MECH_TOP) + | And(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) + | (has_claw & Has(KeyItem.BELL)) + ) + ), + (R.MECH_TP_CONNECTION, R.HOTP_FALL_BOTTOM): has_claw | HasSwitch(Switch.MECH_MAZE_BACKDOOR), + (R.MECH_TP_CONNECTION, R.MECH_TOP): has_claw | HasWhite(WhiteDoor.MECH_TOP), + (R.MECH_TP_CONNECTION, R.MECH_CHARACTER_SWAPS): ( + (has_arias & (HasWhite(WhiteDoor.MECH_TOP, otherwise=True) | Has(KeyItem.BELL))) + | HasSwitch(Switch.MECH_ARIAS_CYCLOPS) ), + (R.MECH_CHARACTER_SWAPS, R.MECH_CLOAK_CONNECTION): ( + (HasSwitch(Crystal.MECH_TRIPLE_1, Crystal.MECH_TRIPLE_2, Crystal.MECH_TRIPLE_3) | otherwise_crystal) + & can_extra_height + ), + (R.MECH_CHARACTER_SWAPS, R.MECH_TP_CONNECTION): has_arias | HasSwitch(Switch.MECH_ARIAS_CYCLOPS, otherwise=True), (R.MECH_CLOAK_CONNECTION, R.MECH_CHARACTER_SWAPS): HasSwitch( Crystal.MECH_TRIPLE_1, Crystal.MECH_TRIPLE_2, Crystal.MECH_TRIPLE_3, ), - (R.MECH_CLOAK_CONNECTION, R.MECH_CLOAK): And( - Has(Eye.BLUE), - Or(HasSwitch(Crystal.MECH_CLOAK), otherwise_crystal), - ), - (R.MECH_BOSS_SWITCHES, R.MECH_CLOAK_CONNECTION): Or( - HasSwitch(Switch.MECH_BLOCK_STAIRS), - HasSwitch(Crystal.MECH_SLIMES), - otherwise_crystal, + (R.MECH_CLOAK_CONNECTION, R.MECH_CLOAK): Has(Eye.BLUE) & (HasSwitch(Crystal.MECH_CLOAK) | otherwise_crystal), + (R.MECH_BOSS_SWITCHES, R.MECH_CLOAK_CONNECTION): ( + HasSwitch(Switch.MECH_BLOCK_STAIRS) | HasSwitch(Crystal.MECH_SLIMES) | otherwise_crystal ), (R.MECH_BOSS_SWITCHES, R.MECH_CHAINS): HasSwitch(Switch.MECH_TO_BOSS_1, otherwise=True), (R.MECH_BOSS_SWITCHES, R.MECH_BOSS_CONNECTION): HasSwitch( @@ -444,21 +378,20 @@ Switch.MECH_TO_BOSS_2, otherwise=True, ), - (R.MECH_BOSS_CONNECTION, R.MECH_BOSS): Or( - HasSwitch(Switch.MECH_BOSS_2, otherwise=True), - And(HasAll(KeyItem.BLOCK, KeyItem.BELL), Or(Has(Character.KYULI), can_uppies)), + (R.MECH_BOSS_CONNECTION, R.MECH_BOSS): ( + HasSwitch(Switch.MECH_BOSS_2, otherwise=True) | (has_block & Has(KeyItem.BELL) & (has_kyuli | can_uppies)) ), (R.MECH_BOSS_CONNECTION, R.MECH_BRAM_TUNNEL_CONNECTION): HasSwitch(Switch.MECH_BOSS_1, otherwise=True), (R.MECH_BRAM_TUNNEL_CONNECTION, R.MECH_BOSS_CONNECTION): HasSwitch(Switch.MECH_BOSS_1), - (R.MECH_BRAM_TUNNEL_CONNECTION, R.MECH_BRAM_TUNNEL): Has(KeyItem.STAR), - (R.MECH_BRAM_TUNNEL, R.MECH_BRAM_TUNNEL_CONNECTION): Has(KeyItem.STAR), - (R.MECH_BRAM_TUNNEL, R.HOTP_START_BOTTOM): Has(KeyItem.STAR), + (R.MECH_BRAM_TUNNEL_CONNECTION, R.MECH_BRAM_TUNNEL): has_star, + (R.MECH_BRAM_TUNNEL, R.MECH_BRAM_TUNNEL_CONNECTION): has_star, + (R.MECH_BRAM_TUNNEL, R.HOTP_START_BOTTOM): has_star, (R.MECH_BOSS, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), (R.MECH_BOSS, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.MECH_BOSS, R.TR_START): HasElevator(Elevator.TR), - (R.MECH_BOSS, R.MECH_TRIPLE_SWITCHES): And( - can_crystal, - HasSwitch(Switch.MECH_TO_BOSS_1, Crystal.MECH_TRIPLE_1, Crystal.MECH_TRIPLE_2, Crystal.MECH_TRIPLE_3), + (R.MECH_BOSS, R.MECH_TRIPLE_SWITCHES): ( + can_crystal + & HasSwitch(Switch.MECH_TO_BOSS_1, Crystal.MECH_TRIPLE_1, Crystal.MECH_TRIPLE_2, Crystal.MECH_TRIPLE_3) ), (R.MECH_BOSS, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.MECH_BOSS, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), @@ -468,104 +401,79 @@ (R.MECH_BOSS, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.MECH_BOSS, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), (R.HOTP_START, R.MECH_BOSS): Has(Eye.BLUE), - (R.HOTP_START, R.HOTP_START_BOTTOM): Or( - Has(KeyItem.STAR), - And( - Or(HasWhite(WhiteDoor.HOTP_START), CanReachRegion(R.HOTP_START_LEFT, options=white_off)), - Has(Eye.BLUE), - ), + (R.HOTP_START, R.HOTP_START_BOTTOM): ( + has_star + | (Has(Eye.BLUE) & (HasWhite(WhiteDoor.HOTP_START) | CanReachRegion(R.HOTP_START_LEFT, options=white_off))) ), (R.HOTP_START, R.HOTP_START_MID): HasSwitch(Switch.HOTP_1ST_ROOM, otherwise=True), - (R.HOTP_START_MID, R.HOTP_START_LEFT): Or( - HasSwitch(Switch.HOTP_LEFT_3, otherwise=True), - And(Has(KeyItem.STAR), HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True)), + (R.HOTP_START_MID, R.HOTP_START_LEFT): ( + HasSwitch(Switch.HOTP_LEFT_3, otherwise=True) + | (has_star & HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True)) ), (R.HOTP_START_MID, R.HOTP_START_BOTTOM_MID): HasSwitch(Switch.HOTP_GHOSTS, otherwise=True), - (R.HOTP_START_MID, R.HOTP_LOWER_VOID): HardLogic(HasAny(Character.ALGUS, ShopUpgrade.BRAM_WHIPLASH)), - (R.HOTP_LOWER_VOID, R.HOTP_UPPER_VOID): HasAll(KeyItem.VOID, KeyItem.CLAW), + (R.HOTP_START_MID, R.HOTP_LOWER_VOID): HardLogic(has_algus | has_bram_whiplash), + (R.HOTP_LOWER_VOID, R.HOTP_UPPER_VOID): Has(KeyItem.VOID) & has_claw, (R.HOTP_START_LEFT, R.HOTP_ELEVATOR): HasSwitch(Switch.HOTP_LEFT_BACKTRACK), - (R.HOTP_START_LEFT, R.HOTP_START_MID): Or( - HasSwitch(Switch.HOTP_LEFT_3), - And(Has(KeyItem.STAR), HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True)), + (R.HOTP_START_LEFT, R.HOTP_START_MID): ( + HasSwitch(Switch.HOTP_LEFT_3) | (has_star & HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True)) ), - (R.HOTP_START_BOTTOM, R.MECH_BRAM_TUNNEL): Has(KeyItem.STAR), - (R.HOTP_START_BOTTOM, R.HOTP_START): Or( - Has(KeyItem.STAR), - And(HasWhite(WhiteDoor.HOTP_START), Has(Eye.BLUE)), - ), - (R.HOTP_START_BOTTOM, R.HOTP_START_BOTTOM_MID): HasAll(KeyItem.BLOCK, KeyItem.BELL, KeyItem.STAR), - (R.HOTP_START_BOTTOM, R.HOTP_LOWER): Or( - HasSwitch(Switch.HOTP_BELOW_START), - CanReachRegion(R.HOTP_START_BOTTOM_MID, options=switch_off), + (R.HOTP_START_BOTTOM, R.MECH_BRAM_TUNNEL): has_star, + (R.HOTP_START_BOTTOM, R.HOTP_START): has_star | (HasWhite(WhiteDoor.HOTP_START) & Has(Eye.BLUE)), + (R.HOTP_START_BOTTOM, R.HOTP_START_BOTTOM_MID): has_block & has_star & Has(KeyItem.BELL), + (R.HOTP_START_BOTTOM, R.HOTP_LOWER): ( + HasSwitch(Switch.HOTP_BELOW_START) | CanReachRegion(R.HOTP_START_BOTTOM_MID, options=switch_off) ), (R.HOTP_START_BOTTOM_MID, R.HOTP_START_MID): HasSwitch(Switch.HOTP_GHOSTS), - (R.HOTP_START_BOTTOM_MID, R.HOTP_START_BOTTOM): Has(KeyItem.STAR), + (R.HOTP_START_BOTTOM_MID, R.HOTP_START_BOTTOM): has_star, (R.HOTP_LOWER, R.HOTP_START_BOTTOM): HasSwitch(Switch.HOTP_BELOW_START), (R.HOTP_LOWER, R.HOTP_EPIMETHEUS): HasBlue(BlueDoor.HOTP_STATUE, otherwise=True), - (R.HOTP_LOWER, R.HOTP_TP_TUTORIAL): Or( - HasSwitch(Crystal.HOTP_LOWER), - HasSwitch(Switch.HOTP_LOWER_SHORTCUT), - otherwise_crystal, - ), - (R.HOTP_LOWER, R.HOTP_MECH_VOID_CONNECTION): Or( - HasSwitch(Crystal.HOTP_BOTTOM), - HardLogic(Has(ShopUpgrade.KYULI_RAY), options=switch_off), + (R.HOTP_LOWER, R.HOTP_TP_TUTORIAL): ( + HasSwitch(Crystal.HOTP_LOWER) | HasSwitch(Switch.HOTP_LOWER_SHORTCUT) | otherwise_crystal ), - (R.HOTP_EPIMETHEUS, R.MECH_AFTER_BK): Has(KeyItem.CLAW), - (R.HOTP_MECH_VOID_CONNECTION, R.HOTP_AMULET_CONNECTION): Or( - HasSwitch(Crystal.HOTP_ROCK_ACCESS), - otherwise_crystal, + (R.HOTP_LOWER, R.HOTP_MECH_VOID_CONNECTION): ( + HasSwitch(Crystal.HOTP_BOTTOM) | HardLogic(has_kyuli_ray, options=switch_off) ), + (R.HOTP_EPIMETHEUS, R.MECH_AFTER_BK): has_claw, + (R.HOTP_MECH_VOID_CONNECTION, R.HOTP_AMULET_CONNECTION): HasSwitch(Crystal.HOTP_ROCK_ACCESS) | otherwise_crystal, (R.HOTP_MECH_VOID_CONNECTION, R.MECH_LOWER_VOID): Has(Eye.BLUE), - (R.HOTP_MECH_VOID_CONNECTION, R.HOTP_LOWER): Or(HasSwitch(Crystal.HOTP_BOTTOM), otherwise_crystal), - (R.HOTP_AMULET_CONNECTION, R.HOTP_AMULET): HasAll(KeyItem.CLAW, Eye.RED, Eye.BLUE), + (R.HOTP_MECH_VOID_CONNECTION, R.HOTP_LOWER): HasSwitch(Crystal.HOTP_BOTTOM) | otherwise_crystal, + (R.HOTP_AMULET_CONNECTION, R.HOTP_AMULET): has_claw & HasAll(Eye.RED, Eye.BLUE), (R.HOTP_AMULET_CONNECTION, R.GT_BUTT): HasSwitch(Switch.HOTP_ROCK, otherwise=True), - (R.HOTP_AMULET_CONNECTION, R.HOTP_MECH_VOID_CONNECTION): Or( - HasSwitch(Crystal.HOTP_ROCK_ACCESS), - otherwise_crystal, - ), - (R.HOTP_BELL_CAMPFIRE, R.HOTP_LOWER_ARIAS): And(Has(Character.ARIAS), Or(Has(KeyItem.BELL), can_uppies)), - (R.HOTP_BELL_CAMPFIRE, R.HOTP_RED_KEY): HasAll(Eye.GREEN, KeyItem.CLOAK), + (R.HOTP_AMULET_CONNECTION, R.HOTP_MECH_VOID_CONNECTION): HasSwitch(Crystal.HOTP_ROCK_ACCESS) | otherwise_crystal, + (R.HOTP_BELL_CAMPFIRE, R.HOTP_LOWER_ARIAS): has_arias & (Has(KeyItem.BELL) | can_uppies), + (R.HOTP_BELL_CAMPFIRE, R.HOTP_RED_KEY): Has(Eye.GREEN) & has_cloak, (R.HOTP_BELL_CAMPFIRE, R.HOTP_CATH_CONNECTION): Has(Eye.GREEN), - (R.HOTP_BELL_CAMPFIRE, R.HOTP_BELL): And( - HasSwitch(Switch.HOTP_BELL_ACCESS, otherwise=True), - Or( - HasSwitch(Crystal.HOTP_BELL_ACCESS), - otherwise_crystal, - And(HasAll(KeyItem.BELL, KeyItem.BLOCK), Or(Has(Character.KYULI), can_uppies)), - HardLogic(Has(KeyItem.CLAW)), - ), - ), - (R.HOTP_CATH_CONNECTION, R.CATH_START): And( - HasAll(KeyItem.VOID, KeyItem.CLAW), - Or(HasRed(RedDoor.CATH), CanReachRegion(R.HOTP_RED_KEY, options=red_off)), - ), - (R.HOTP_LOWER_ARIAS, R.HOTP_BELL_CAMPFIRE): Has(Character.ARIAS), - (R.HOTP_LOWER_ARIAS, R.HOTP_GHOST_BLOOD): Or( - HasSwitch(Switch.HOTP_TELEPORTS, otherwise=True), - And(HasAll(KeyItem.BLOCK, KeyItem.BELL), Or(Has(Character.KYULI), can_uppies)), + (R.HOTP_BELL_CAMPFIRE, R.HOTP_BELL): ( + HasSwitch(Switch.HOTP_BELL_ACCESS, otherwise=True) + & ( + HasSwitch(Crystal.HOTP_BELL_ACCESS) + | otherwise_crystal + | (has_block & Has(KeyItem.BELL) & (has_kyuli | can_uppies)) + | HardLogic(has_claw) + ) + ), + (R.HOTP_CATH_CONNECTION, R.CATH_START): ( + Has(KeyItem.VOID) & has_claw & (HasRed(RedDoor.CATH) | CanReachRegion(R.HOTP_RED_KEY, options=red_off)) + ), + (R.HOTP_LOWER_ARIAS, R.HOTP_BELL_CAMPFIRE): has_arias, + (R.HOTP_LOWER_ARIAS, R.HOTP_GHOST_BLOOD): ( + HasSwitch(Switch.HOTP_TELEPORTS, otherwise=True) | (has_block & Has(KeyItem.BELL) & (has_kyuli | can_uppies)) ), (R.HOTP_GHOST_BLOOD, R.HOTP_EYEBALL): HasSwitch(Switch.HOTP_GHOST_BLOOD, otherwise=True), (R.HOTP_GHOST_BLOOD, R.HOTP_WORM_SHORTCUT): HasSwitch(Switch.HOTP_EYEBALL_SHORTCUT), (R.HOTP_WORM_SHORTCUT, R.HOTP_GHOST_BLOOD): HasSwitch(Switch.HOTP_EYEBALL_SHORTCUT, otherwise=True), (R.HOTP_WORM_SHORTCUT, R.HOTP_ELEVATOR): HasSwitch(Switch.HOTP_WORM_PILLAR), - (R.HOTP_ELEVATOR, R.HOTP_OLD_MAN): And( - Has(KeyItem.CLOAK), - Or(HasSwitch(Face.HOTP_OLD_MAN), otherwise_bow), - ), + (R.HOTP_ELEVATOR, R.HOTP_OLD_MAN): has_cloak & (HasSwitch(Face.HOTP_OLD_MAN) | otherwise_bow), (R.HOTP_ELEVATOR, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), - (R.HOTP_ELEVATOR, R.HOTP_TOP_LEFT): Has(KeyItem.CLAW), + (R.HOTP_ELEVATOR, R.HOTP_TOP_LEFT): has_claw, (R.HOTP_ELEVATOR, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.HOTP_ELEVATOR, R.TR_START): HasElevator(Elevator.TR), (R.HOTP_ELEVATOR, R.HOTP_START_LEFT): HasSwitch(Switch.HOTP_LEFT_BACKTRACK, otherwise=True), (R.HOTP_ELEVATOR, R.HOTP_WORM_SHORTCUT): HasSwitch(Switch.HOTP_WORM_PILLAR, otherwise=True), (R.HOTP_ELEVATOR, R.HOTP_SPIKE_TP_SECRET): Has(KeyItem.CHALICE), - (R.HOTP_ELEVATOR, R.HOTP_CLAW_LEFT): Or( - And(HasSwitch(Switch.HOTP_TO_CLAW_2, otherwise=True), can_extra_height), - And( - Has(KeyItem.BELL), - Or(HasAll(KeyItem.CLAW, KeyItem.CLOAK), HasAll(Character.KYULI, KeyItem.BLOCK)), - ), + (R.HOTP_ELEVATOR, R.HOTP_CLAW_LEFT): ( + (HasSwitch(Switch.HOTP_TO_CLAW_2, otherwise=True) & can_extra_height) + | (Has(KeyItem.BELL) & ((has_claw & has_cloak) | (has_kyuli & has_block))) ), (R.HOTP_ELEVATOR, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.HOTP_ELEVATOR, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), @@ -575,64 +483,44 @@ (R.HOTP_ELEVATOR, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.HOTP_CLAW_LEFT, R.HOTP_ELEVATOR): can_extra_height, (R.HOTP_CLAW_LEFT, R.HOTP_TOP_LEFT): HasWhite(WhiteDoor.HOTP_CLAW, otherwise=True), - (R.HOTP_CLAW_LEFT, R.HOTP_CLAW): Has(KeyItem.STAR), - (R.HOTP_TOP_LEFT, R.HOTP_ABOVE_OLD_MAN): And( - Has(Eye.GREEN), - Or( - HasSwitch(Switch.HOTP_TO_ABOVE_OLD_MAN, otherwise=True), - And(HasAll(KeyItem.BLOCK, KeyItem.BELL), can_uppies), - ), - ), - (R.HOTP_CLAW_CAMPFIRE, R.HOTP_CLAW): And( - HasSwitch(Switch.HOTP_CLAW_ACCESS, otherwise=True), - Or(Has(Character.KYULI), can_block_in_wall), - ), - (R.HOTP_CLAW_CAMPFIRE, R.HOTP_HEART): Or(HasSwitch(Crystal.HOTP_AFTER_CLAW), otherwise_crystal), - (R.HOTP_CLAW, R.HOTP_CLAW_CAMPFIRE): And(Has(KeyItem.CLAW), HasSwitch(Switch.HOTP_CLAW_ACCESS)), - (R.HOTP_CLAW, R.HOTP_CLAW_LEFT): Has(KeyItem.STAR), - (R.HOTP_HEART, R.HOTP_CLAW_CAMPFIRE): Or( - HasSwitch(Crystal.HOTP_AFTER_CLAW), - HardLogic( - Or( - HasAll(KeyItem.CLOAK, KeyItem.BANISH, ShopUpgrade.ALGUS_ARCANIST), - HasAll(Character.ALGUS, KeyItem.ICARUS), - Has(ShopUpgrade.KYULI_RAY), - ), + (R.HOTP_CLAW_LEFT, R.HOTP_CLAW): has_star, + (R.HOTP_TOP_LEFT, R.HOTP_ABOVE_OLD_MAN): ( + Has(Eye.GREEN) + & (HasSwitch(Switch.HOTP_TO_ABOVE_OLD_MAN, otherwise=True) | (has_block & Has(KeyItem.BELL) & can_uppies)) + ), + (R.HOTP_CLAW_CAMPFIRE, R.HOTP_CLAW): ( + HasSwitch(Switch.HOTP_CLAW_ACCESS, otherwise=True) & (has_kyuli | can_block_in_wall) + ), + (R.HOTP_CLAW_CAMPFIRE, R.HOTP_HEART): HasSwitch(Crystal.HOTP_AFTER_CLAW) | otherwise_crystal, + (R.HOTP_CLAW, R.HOTP_CLAW_CAMPFIRE): has_claw & HasSwitch(Switch.HOTP_CLAW_ACCESS), + (R.HOTP_CLAW, R.HOTP_CLAW_LEFT): has_star, + (R.HOTP_HEART, R.HOTP_CLAW_CAMPFIRE): ( + HasSwitch(Crystal.HOTP_AFTER_CLAW) + | HardLogic( + ((has_cloak & has_banish & has_algus_arcanist) | (has_algus & Has(KeyItem.ICARUS)) | has_kyuli_ray), options=switch_off, - ), + ) ), - (R.HOTP_HEART, R.HOTP_UPPER_ARIAS): Has(Character.ARIAS), - (R.HOTP_HEART, R.HOTP_BOSS_CAMPFIRE): And( - Has(KeyItem.CLAW), - Or(Has(KeyItem.ICARUS), HasAll(KeyItem.BLOCK, KeyItem.BELL), HasSwitch(Crystal.HOTP_HEART)), + (R.HOTP_HEART, R.HOTP_UPPER_ARIAS): has_arias, + (R.HOTP_HEART, R.HOTP_BOSS_CAMPFIRE): ( + has_claw & (Has(KeyItem.ICARUS) | (has_block & Has(KeyItem.BELL)) | HasSwitch(Crystal.HOTP_HEART)) ), - (R.HOTP_UPPER_ARIAS, R.HOTP_BOSS_CAMPFIRE): Has(KeyItem.CLAW), - (R.HOTP_BOSS_CAMPFIRE, R.MECH_TRIPLE_SWITCHES): And( - HasAll(Eye.GREEN, KeyItem.CLOAK), - HasSwitch(Switch.HOTP_TP_PUZZLE, Switch.MECH_ARIAS_CYCLOPS), + (R.HOTP_UPPER_ARIAS, R.HOTP_BOSS_CAMPFIRE): has_claw, + (R.HOTP_BOSS_CAMPFIRE, R.MECH_TRIPLE_SWITCHES): ( + Has(Eye.GREEN) & has_cloak & HasSwitch(Switch.HOTP_TP_PUZZLE, Switch.MECH_ARIAS_CYCLOPS) ), - (R.HOTP_BOSS_CAMPFIRE, R.HOTP_MAIDEN): And( - HasBlue(BlueDoor.HOTP_MAIDEN, otherwise=True), - Or(Has(KeyItem.SWORD), HasAll(Character.KYULI, KeyItem.BLOCK, KeyItem.BELL)), + (R.HOTP_BOSS_CAMPFIRE, R.HOTP_MAIDEN): ( + HasBlue(BlueDoor.HOTP_MAIDEN, otherwise=True) & (has_sword | (has_kyuli & has_block & Has(KeyItem.BELL))) ), (R.HOTP_BOSS_CAMPFIRE, R.HOTP_TP_PUZZLE): Has(Eye.GREEN), - (R.HOTP_BOSS_CAMPFIRE, R.HOTP_BOSS): Or( - HasWhite(WhiteDoor.HOTP_BOSS), - Has(Character.ARIAS, options=white_off), - ), - (R.HOTP_TP_PUZZLE, R.HOTP_TP_FALL_TOP): Or( - Has(KeyItem.STAR), - HasSwitch(Switch.HOTP_TP_PUZZLE, otherwise=True), - ), - (R.HOTP_TP_FALL_TOP, R.HOTP_FALL_BOTTOM): Has(KeyItem.CLOAK), - (R.HOTP_TP_FALL_TOP, R.HOTP_TP_PUZZLE): Or(Has(KeyItem.STAR), HasSwitch(Switch.HOTP_TP_PUZZLE)), - (R.HOTP_TP_FALL_TOP, R.HOTP_GAUNTLET_CONNECTION): Has(KeyItem.CLAW), - (R.HOTP_TP_FALL_TOP, R.HOTP_BOSS_CAMPFIRE): Or( - Has(Character.KYULI), - And(Has(KeyItem.BLOCK), can_combo_height), - ), - (R.HOTP_GAUNTLET_CONNECTION, R.HOTP_GAUNTLET): And(HasAll(KeyItem.CLAW, KeyItem.BELL), can_kill_ghosts), - (R.HOTP_FALL_BOTTOM, R.HOTP_TP_FALL_TOP): Has(KeyItem.CLAW), + (R.HOTP_BOSS_CAMPFIRE, R.HOTP_BOSS): HasWhite(WhiteDoor.HOTP_BOSS) | And(has_arias, options=white_off), + (R.HOTP_TP_PUZZLE, R.HOTP_TP_FALL_TOP): has_star | HasSwitch(Switch.HOTP_TP_PUZZLE, otherwise=True), + (R.HOTP_TP_FALL_TOP, R.HOTP_FALL_BOTTOM): has_cloak, + (R.HOTP_TP_FALL_TOP, R.HOTP_TP_PUZZLE): has_star | HasSwitch(Switch.HOTP_TP_PUZZLE), + (R.HOTP_TP_FALL_TOP, R.HOTP_GAUNTLET_CONNECTION): has_claw, + (R.HOTP_TP_FALL_TOP, R.HOTP_BOSS_CAMPFIRE): has_kyuli | (has_block & can_combo_height), + (R.HOTP_GAUNTLET_CONNECTION, R.HOTP_GAUNTLET): has_claw & Has(KeyItem.BELL) & can_kill_ghosts, + (R.HOTP_FALL_BOTTOM, R.HOTP_TP_FALL_TOP): has_claw, (R.HOTP_FALL_BOTTOM, R.HOTP_UPPER_VOID): Has(Eye.GREEN), (R.HOTP_UPPER_VOID, R.HOTP_FALL_BOTTOM): Has(Eye.GREEN), (R.HOTP_UPPER_VOID, R.HOTP_LOWER_VOID): Has(KeyItem.VOID), @@ -645,109 +533,80 @@ (R.HOTP_BOSS, R.GT_BOSS): HasElevator(Elevator.GT_2), (R.HOTP_BOSS, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.HOTP_BOSS, R.MECH_BOSS): HasElevator(Elevator.MECH_2), - (R.ROA_START, R.ROA_WORMS): Or( - HasSwitch(Crystal.ROA_1ST_ROOM), + (R.ROA_START, R.ROA_WORMS): ( # this should be more complicated - And(Has(KeyItem.BELL), can_crystal, options=switch_off), + HasSwitch(Crystal.ROA_1ST_ROOM) | And(Has(KeyItem.BELL) & can_crystal, options=switch_off) ), - (R.ROA_WORMS, R.ROA_START): Or( - HasSwitch(Switch.ROA_WORMS, otherwise=True), - HasSwitch(Crystal.ROA_1ST_ROOM), - otherwise_crystal, + (R.ROA_WORMS, R.ROA_START): ( + HasSwitch(Switch.ROA_WORMS, otherwise=True) | HasSwitch(Crystal.ROA_1ST_ROOM) | otherwise_crystal ), - (R.ROA_WORMS, R.ROA_WORMS_CONNECTION): Or( - HasWhite(WhiteDoor.ROA_WORMS), - HasSwitch(Switch.ROA_WORMS, otherwise=True, options=white_off), + (R.ROA_WORMS, R.ROA_WORMS_CONNECTION): ( + HasWhite(WhiteDoor.ROA_WORMS) | HasSwitch(Switch.ROA_WORMS, otherwise=True, options=white_off) ), - (R.ROA_WORMS, R.ROA_LOWER_VOID_CONNECTION): Has(KeyItem.CLAW), + (R.ROA_WORMS, R.ROA_LOWER_VOID_CONNECTION): has_claw, (R.ROA_HEARTS, R.ROA_BOTTOM_ASCEND): HasSwitch(Switch.ROA_1ST_SHORTCUT), (R.ROA_WORMS_CONNECTION, R.ROA_WORMS): HasWhite(WhiteDoor.ROA_WORMS), - (R.ROA_WORMS_CONNECTION, R.ROA_HEARTS): Or( - HasSwitch(Switch.ROA_AFTER_WORMS, otherwise=True), - Has(KeyItem.STAR), + (R.ROA_WORMS_CONNECTION, R.ROA_HEARTS): HasSwitch(Switch.ROA_AFTER_WORMS, otherwise=True) | has_star, + (R.ROA_HEARTS, R.ROA_WORMS_CONNECTION): ( + HasSwitch(Switch.ROA_AFTER_WORMS) | (has_star & Has(KeyItem.BELL) & can_extra_height) ), - (R.ROA_HEARTS, R.ROA_WORMS_CONNECTION): Or( - HasSwitch(Switch.ROA_AFTER_WORMS), - And(HasAll(KeyItem.STAR, KeyItem.BELL), can_extra_height), - ), - (R.ROA_SPIKE_CLIMB, R.ROA_BOTTOM_ASCEND): Has(KeyItem.CLAW), + (R.ROA_SPIKE_CLIMB, R.ROA_BOTTOM_ASCEND): has_claw, (R.ROA_BOTTOM_ASCEND, R.ROA_TOP_ASCENT): HasWhite(WhiteDoor.ROA_ASCEND, otherwise=True), - (R.ROA_BOTTOM_ASCEND, R.ROA_TRIPLE_REAPER): Or( - HasSwitch(Switch.ROA_ASCEND, otherwise=True), - HasAll(Character.KYULI, KeyItem.BLOCK, KeyItem.BELL), + (R.ROA_BOTTOM_ASCEND, R.ROA_TRIPLE_REAPER): ( + HasSwitch(Switch.ROA_ASCEND, otherwise=True) | (has_kyuli & has_block & Has(KeyItem.BELL)) ), - (R.ROA_TRIPLE_REAPER, R.ROA_ARENA): Or(HasSwitch(Crystal.ROA_3_REAPERS), otherwise_crystal), - (R.ROA_ARENA, R.ROA_FLAMES_CONNECTION): Has(KeyItem.CLAW), + (R.ROA_TRIPLE_REAPER, R.ROA_ARENA): HasSwitch(Crystal.ROA_3_REAPERS) | otherwise_crystal, + (R.ROA_ARENA, R.ROA_FLAMES_CONNECTION): has_claw, (R.ROA_ARENA, R.ROA_TRIPLE_REAPER): HasSwitch(Crystal.ROA_3_REAPERS), - (R.ROA_ARENA, R.ROA_LOWER_VOID_CONNECTION): Has(Character.KYULI), + (R.ROA_ARENA, R.ROA_LOWER_VOID_CONNECTION): has_kyuli, (R.ROA_LOWER_VOID_CONNECTION, R.ROA_LOWER_VOID): HasSwitch(Switch.ROA_LOWER_VOID), - (R.ROA_LOWER_VOID_CONNECTION, R.ROA_ARIAS_BABY_GORGON_CONNECTION): Or( - Has(Character.KYULI), - can_uppies, - can_block_in_wall, - ), + (R.ROA_LOWER_VOID_CONNECTION, R.ROA_ARIAS_BABY_GORGON_CONNECTION): has_kyuli | can_uppies | can_block_in_wall, (R.ROA_LOWER_VOID, R.ROA_UPPER_VOID): Has(KeyItem.VOID), (R.ROA_LOWER_VOID, R.ROA_LOWER_VOID_CONNECTION): HasSwitch(Switch.ROA_LOWER_VOID, otherwise=True), - (R.ROA_ARIAS_BABY_GORGON_CONNECTION, R.ROA_ARIAS_BABY_GORGON): And( - Has(Character.ARIAS), - Or(HardLogic(True_()), Has(KeyItem.BELL, options=easy)), - Or(HasSwitch(Crystal.ROA_BABY_GORGON), otherwise_crystal), - ), - (R.ROA_ARIAS_BABY_GORGON_CONNECTION, R.ROA_FLAMES_CONNECTION): HasAll(KeyItem.STAR, KeyItem.BELL), - (R.ROA_ARIAS_BABY_GORGON, R.ROA_FLAMES): And( - HasSwitch(Switch.ROA_BABY_GORGON), - HasAll(KeyItem.BLOCK, Character.KYULI, KeyItem.BELL), - ), - (R.ROA_ARIAS_BABY_GORGON, R.ROA_ARIAS_BABY_GORGON_CONNECTION): And( - Has(Character.ARIAS), - HasSwitch(Crystal.ROA_BABY_GORGON), - ), - (R.ROA_FLAMES_CONNECTION, R.ROA_WORM_CLIMB): And( - HasBlue(BlueDoor.ROA_FLAMES, otherwise=True), - Has(KeyItem.CLAW), - ), - (R.ROA_FLAMES_CONNECTION, R.ROA_LEFT_ASCENT): And( - Or(HasSwitch(Crystal.ROA_LEFT_ASCEND), And(can_crystal, Has(KeyItem.BELL), options=switch_off)), - can_extra_height, - ), - (R.ROA_FLAMES_CONNECTION, R.ROA_ARIAS_BABY_GORGON_CONNECTION): HasAll(KeyItem.STAR), - (R.ROA_FLAMES_CONNECTION, R.ROA_ARIAS_BABY_GORGON): HardLogic( - HasAny(ShopUpgrade.BRAM_AXE, ShopUpgrade.KYULI_RAY), - ), - (R.ROA_FLAMES_CONNECTION, R.ROA_FLAMES): And(HasAll(KeyItem.GAUNTLET, KeyItem.BELL), can_extra_height), - (R.ROA_FLAMES_CONNECTION, R.ROA_LEFT_ASCENT_CRYSTAL): And( - HasAll(KeyItem.BELL, Character.KYULI), - can_crystal, - ), + (R.ROA_ARIAS_BABY_GORGON_CONNECTION, R.ROA_ARIAS_BABY_GORGON): ( + has_arias + & (HardLogic(True_()) | Has(KeyItem.BELL, options=easy)) + & (HasSwitch(Crystal.ROA_BABY_GORGON) | otherwise_crystal) + ), + (R.ROA_ARIAS_BABY_GORGON_CONNECTION, R.ROA_FLAMES_CONNECTION): has_star & Has(KeyItem.BELL), + (R.ROA_ARIAS_BABY_GORGON, R.ROA_FLAMES): ( + HasSwitch(Switch.ROA_BABY_GORGON) & has_block & has_kyuli & Has(KeyItem.BELL) + ), + (R.ROA_ARIAS_BABY_GORGON, R.ROA_ARIAS_BABY_GORGON_CONNECTION): has_arias & HasSwitch(Crystal.ROA_BABY_GORGON), + (R.ROA_FLAMES_CONNECTION, R.ROA_WORM_CLIMB): HasBlue(BlueDoor.ROA_FLAMES, otherwise=True) & has_claw, + (R.ROA_FLAMES_CONNECTION, R.ROA_LEFT_ASCENT): ( + (HasSwitch(Crystal.ROA_LEFT_ASCEND) | And(can_crystal & Has(KeyItem.BELL), options=switch_off)) + & can_extra_height + ), + (R.ROA_FLAMES_CONNECTION, R.ROA_ARIAS_BABY_GORGON_CONNECTION): has_star, + (R.ROA_FLAMES_CONNECTION, R.ROA_ARIAS_BABY_GORGON): HardLogic(has_bram_axe | has_kyuli_ray), + (R.ROA_FLAMES_CONNECTION, R.ROA_FLAMES): has_gauntlet & Has(KeyItem.BELL) & can_extra_height, + (R.ROA_FLAMES_CONNECTION, R.ROA_LEFT_ASCENT_CRYSTAL): Has(KeyItem.BELL) & has_kyuli & can_crystal, (R.ROA_FLAMES, R.ROA_ARIAS_BABY_GORGON): HasSwitch(Switch.ROA_BABY_GORGON, otherwise=True), - (R.ROA_WORM_CLIMB, R.ROA_RIGHT_BRANCH): Has(KeyItem.CLAW), - (R.ROA_RIGHT_BRANCH, R.ROA_MIDDLE): Has(KeyItem.STAR), - (R.ROA_LEFT_ASCENT, R.ROA_FLAMES_CONNECTION): And( - Or(HasSwitch(Crystal.ROA_LEFT_ASCEND), otherwise_crystal), + (R.ROA_WORM_CLIMB, R.ROA_RIGHT_BRANCH): has_claw, + (R.ROA_RIGHT_BRANCH, R.ROA_MIDDLE): has_star, + (R.ROA_LEFT_ASCENT, R.ROA_FLAMES_CONNECTION): ( # this is overly restrictive, but whatever - HasAll(Character.KYULI, KeyItem.BELL), + (HasSwitch(Crystal.ROA_LEFT_ASCEND) | otherwise_crystal) & (has_kyuli & Has(KeyItem.BELL)) ), (R.ROA_LEFT_ASCENT, R.ROA_TOP_ASCENT): HasSwitch(Switch.ROA_ASCEND_SHORTCUT), - (R.ROA_LEFT_ASCENT, R.ROA_LEFT_ASCENT_CRYSTAL): Has(Character.ALGUS), + (R.ROA_LEFT_ASCENT, R.ROA_LEFT_ASCENT_CRYSTAL): has_algus, (R.ROA_TOP_ASCENT, R.ROA_TRIPLE_SWITCH): can_extra_height, (R.ROA_TOP_ASCENT, R.ROA_LEFT_ASCENT): HasSwitch(Switch.ROA_ASCEND_SHORTCUT), - (R.ROA_TOP_ASCENT, R.ROA_MIDDLE): And(can_extra_height, HasSwitch(Switch.ROA_ASCEND_SHORTCUT)), - (R.ROA_TRIPLE_SWITCH, R.ROA_MIDDLE): And( - Or(HasSwitch(Switch.ROA_TRIPLE_1, Switch.ROA_TRIPLE_3), otherwise_crystal), - HasAll(KeyItem.CLAW, KeyItem.BELL), + (R.ROA_TOP_ASCENT, R.ROA_MIDDLE): can_extra_height & HasSwitch(Switch.ROA_ASCEND_SHORTCUT), + (R.ROA_TRIPLE_SWITCH, R.ROA_MIDDLE): ( + (HasSwitch(Switch.ROA_TRIPLE_1, Switch.ROA_TRIPLE_3) | otherwise_crystal) & has_claw & Has(KeyItem.BELL) ), (R.ROA_MIDDLE, R.ROA_LEFT_SWITCH): can_extra_height, - (R.ROA_MIDDLE, R.ROA_RIGHT_BRANCH): Has(KeyItem.STAR), - (R.ROA_MIDDLE, R.ROA_RIGHT_SWITCH_1): Or(Has(Character.KYULI), HasSwitch(Switch.ROA_RIGHT_PATH)), - (R.ROA_MIDDLE, R.ROA_MIDDLE_LADDER): Or( + (R.ROA_MIDDLE, R.ROA_RIGHT_BRANCH): has_star, + (R.ROA_MIDDLE, R.ROA_RIGHT_SWITCH_1): has_kyuli | HasSwitch(Switch.ROA_RIGHT_PATH), + (R.ROA_MIDDLE, R.ROA_MIDDLE_LADDER): ( # this could allow more - HasSwitch(Crystal.ROA_LADDER_L, Crystal.ROA_LADDER_R), - And( - can_crystal, - CanReachRegion(R.ROA_LEFT_SWITCH), - CanReachRegion(R.ROA_RIGHT_SWITCH_2), + HasSwitch(Crystal.ROA_LADDER_L, Crystal.ROA_LADDER_R) + | And( + can_crystal & CanReachRegion(R.ROA_LEFT_SWITCH) & CanReachRegion(R.ROA_RIGHT_SWITCH_2), options=switch_off, - ), + ) ), (R.ROA_MIDDLE, R.ROA_TOP_ASCENT): HasSwitch(Switch.ROA_ASCEND_SHORTCUT, otherwise=True), (R.ROA_MIDDLE, R.ROA_TRIPLE_SWITCH): HasSwitch(Switch.ROA_TRIPLE_1, Switch.ROA_TRIPLE_3), @@ -758,70 +617,54 @@ Switch.ROA_SHAFT_R, otherwise=True, ), - (R.ROA_MIDDLE_LADDER, R.ROA_RIGHT_SWITCH_CANDLE): HasAny( - Character.ALGUS, - ShopUpgrade.BRAM_AXE, - ShopUpgrade.BRAM_WHIPLASH, - ), + (R.ROA_MIDDLE_LADDER, R.ROA_RIGHT_SWITCH_CANDLE): has_algus | has_bram_axe | has_bram_whiplash, (R.ROA_UPPER_VOID, R.ROA_LOWER_VOID): Has(KeyItem.VOID), (R.ROA_UPPER_VOID, R.ROA_SP_CONNECTION): HasSwitch(Crystal.ROA_SHAFT, Switch.ROA_SHAFT_DOWNWARDS), - (R.ROA_UPPER_VOID, R.ROA_SPIKE_BALLS): Or(HasSwitch(Crystal.ROA_SPIKE_BALLS), otherwise_crystal), + (R.ROA_UPPER_VOID, R.ROA_SPIKE_BALLS): HasSwitch(Crystal.ROA_SPIKE_BALLS) | otherwise_crystal, (R.ROA_SPIKE_BALLS, R.ROA_SPIKE_SPINNERS): HasWhite(WhiteDoor.ROA_BALLS, otherwise=True), (R.ROA_SPIKE_SPINNERS, R.ROA_SPIDERS_1): HasWhite(WhiteDoor.ROA_SPINNERS, otherwise=True), (R.ROA_SPIKE_SPINNERS, R.ROA_SPIKE_BALLS): HasWhite(WhiteDoor.ROA_BALLS, otherwise=True), - (R.ROA_SPIDERS_1, R.ROA_RED_KEY): Or(HasSwitch(Face.ROA_SPIDERS), otherwise_bow), + (R.ROA_SPIDERS_1, R.ROA_RED_KEY): HasSwitch(Face.ROA_SPIDERS) | otherwise_bow, (R.ROA_SPIDERS_1, R.ROA_SPIDERS_2): can_extra_height, (R.ROA_SPIDERS_2, R.ROA_BLOOD_POT_HALLWAY): HasSwitch(Switch.ROA_SPIDERS, otherwise=True), - (R.ROA_SP_CONNECTION, R.SP_START): Or( - HasRed(RedDoor.SP), - And(HasAll(KeyItem.CLOAK, KeyItem.CLAW, KeyItem.BELL), CanReachRegion(R.ROA_RED_KEY), options=red_off), - ), - (R.ROA_SP_CONNECTION, R.ROA_ELEVATOR): And( - # can probably make it without claw - Has(KeyItem.CLAW), - HasSwitch(Switch.ROA_DARK_ROOM, otherwise=True), + (R.ROA_SP_CONNECTION, R.SP_START): ( + HasRed(RedDoor.SP) + | And(has_cloak & has_claw & Has(KeyItem.BELL) & CanReachRegion(R.ROA_RED_KEY), options=red_off) ), + # can probably make it without claw + (R.ROA_SP_CONNECTION, R.ROA_ELEVATOR): has_claw & HasSwitch(Switch.ROA_DARK_ROOM, otherwise=True), (R.ROA_ELEVATOR, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), (R.ROA_ELEVATOR, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.ROA_ELEVATOR, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), (R.ROA_ELEVATOR, R.TR_START): HasElevator(Elevator.TR), (R.ROA_ELEVATOR, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.ROA_ELEVATOR, R.ROA_ICARUS): HasSwitch(Switch.ROA_ICARUS, otherwise=True), - (R.ROA_ELEVATOR, R.ROA_DARK_CONNECTION): Or( - Has(KeyItem.CLAW), - HasSwitch(Switch.ROA_ELEVATOR, otherwise=True), - ), + (R.ROA_ELEVATOR, R.ROA_DARK_CONNECTION): has_claw | HasSwitch(Switch.ROA_ELEVATOR, otherwise=True), (R.ROA_ELEVATOR, R.APEX): elevator_apex, (R.ROA_ELEVATOR, R.GT_BOSS): HasElevator(Elevator.GT_2), (R.ROA_ELEVATOR, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.ROA_ELEVATOR, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.ROA_DARK_CONNECTION, R.ROA_TOP_CENTAUR): HasSwitch(Switch.ROA_BLOOD_POT), (R.ROA_DARK_CONNECTION, R.DARK_START): can_extra_height, - (R.DARK_START, R.DARK_END): And(Has(KeyItem.CLAW), HasSwitch(Switch.DARKNESS, otherwise=True)), - (R.DARK_END, R.ROA_DARK_EXIT): Has(KeyItem.CLAW), - (R.ROA_DARK_EXIT, R.ROA_ABOVE_CENTAUR_R): And( - HasAll(Character.ARIAS, KeyItem.BELL), - Or(HardLogic(True_()), Has(Character.KYULI)), - ), - (R.ROA_DARK_EXIT, R.ROA_CRYSTAL_ABOVE_CENTAUR): HardLogic(Has(ShopUpgrade.KYULI_RAY)), - (R.ROA_TOP_CENTAUR, R.ROA_DARK_CONNECTION): Or( - HasSwitch(Switch.ROA_BLOOD_POT, otherwise=True), - HasBlue(BlueDoor.ROA_BLOOD, otherwise=True), + (R.DARK_START, R.DARK_END): has_claw & HasSwitch(Switch.DARKNESS, otherwise=True), + (R.DARK_END, R.ROA_DARK_EXIT): has_claw, + (R.ROA_DARK_EXIT, R.ROA_ABOVE_CENTAUR_R): has_arias & Has(KeyItem.BELL) & (HardLogic(True_()) | has_kyuli), + (R.ROA_DARK_EXIT, R.ROA_CRYSTAL_ABOVE_CENTAUR): HardLogic(has_kyuli_ray), + (R.ROA_TOP_CENTAUR, R.ROA_DARK_CONNECTION): ( + HasSwitch(Switch.ROA_BLOOD_POT, otherwise=True) | HasBlue(BlueDoor.ROA_BLOOD, otherwise=True) ), (R.ROA_TOP_CENTAUR, R.ROA_DARK_EXIT): can_extra_height, - (R.ROA_TOP_CENTAUR, R.ROA_BOSS_CONNECTION): Or( - HasSwitch(Crystal.ROA_CENTAUR), - CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR), + (R.ROA_TOP_CENTAUR, R.ROA_BOSS_CONNECTION): ( + HasSwitch(Crystal.ROA_CENTAUR) | CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR) ), - (R.ROA_ABOVE_CENTAUR_R, R.ROA_DARK_EXIT): HasAll(Character.ARIAS, KeyItem.BELL), - (R.ROA_ABOVE_CENTAUR_R, R.ROA_ABOVE_CENTAUR_L): HasAll(KeyItem.STAR, KeyItem.BELL), + (R.ROA_ABOVE_CENTAUR_R, R.ROA_DARK_EXIT): has_arias & Has(KeyItem.BELL), + (R.ROA_ABOVE_CENTAUR_R, R.ROA_ABOVE_CENTAUR_L): has_star & Has(KeyItem.BELL), (R.ROA_ABOVE_CENTAUR_R, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_whiplash, - (R.ROA_ABOVE_CENTAUR_L, R.ROA_ABOVE_CENTAUR_R): HasAll(KeyItem.STAR, KeyItem.BELL), + (R.ROA_ABOVE_CENTAUR_L, R.ROA_ABOVE_CENTAUR_R): has_star & Has(KeyItem.BELL), (R.ROA_ABOVE_CENTAUR_L, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_block, (R.ROA_BOSS_CONNECTION, R.ROA_ABOVE_CENTAUR_L): can_extra_height, - (R.ROA_BOSS_CONNECTION, R.ROA_TOP_CENTAUR): Or( - HasSwitch(Crystal.ROA_CENTAUR), - CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR), + (R.ROA_BOSS_CONNECTION, R.ROA_TOP_CENTAUR): ( + HasSwitch(Crystal.ROA_CENTAUR) | CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR) ), (R.ROA_BOSS_CONNECTION, R.ROA_BOSS): HasSwitch(Switch.ROA_BOSS_ACCESS, otherwise=True), (R.ROA_BOSS, R.ROA_APEX_CONNECTION): Has(Eye.GREEN), @@ -831,10 +674,8 @@ (R.APEX, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), (R.APEX, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.APEX, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), - (R.APEX, R.FINAL_BOSS): And( - HasAll(Eye.RED, Eye.BLUE, Eye.GREEN), - Or(HardLogic(True_()), Has(KeyItem.BELL, options=easy)), - HasGoal(), + (R.APEX, R.FINAL_BOSS): ( + HasAll(Eye.RED, Eye.BLUE, Eye.GREEN) & (HardLogic(True_()) | Has(KeyItem.BELL, options=easy)) & HasGoal() ), (R.APEX, R.ROA_APEX_CONNECTION): HasSwitch(Switch.ROA_APEX_ACCESS), (R.APEX, R.TR_START): HasElevator(Elevator.TR), @@ -842,17 +683,14 @@ (R.APEX, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.APEX, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), (R.APEX, R.GT_BOSS): HasElevator(Elevator.GT_2), - (R.APEX, R.APEX_CENTAUR_ACCESS): And(HasBlue(BlueDoor.APEX, otherwise=True), Has(KeyItem.STAR)), + (R.APEX, R.APEX_CENTAUR_ACCESS): HasBlue(BlueDoor.APEX, otherwise=True) & has_star, (R.APEX, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.APEX, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.APEX_CENTAUR_ACCESS, R.APEX_CENTAUR): Has(KeyItem.ADORNED_KEY), (R.CAVES_START, R.CAVES_EPIMETHEUS): HasBlue(BlueDoor.CAVES, otherwise=True), - (R.CAVES_EPIMETHEUS, R.CAVES_UPPER): Or(Has(Character.KYULI), can_block_in_wall, can_combo_height), + (R.CAVES_EPIMETHEUS, R.CAVES_UPPER): has_kyuli | can_block_in_wall | can_combo_height, (R.CAVES_EPIMETHEUS, R.CAVES_START): HasBlue(BlueDoor.CAVES, otherwise=True), - (R.CAVES_UPPER, R.CAVES_ARENA): Or( - HasAny(KeyItem.SWORD, ShopUpgrade.KYULI_RAY), - And(Has(ShopUpgrade.ALGUS_METEOR), chalice_on_easy), - ), + (R.CAVES_UPPER, R.CAVES_ARENA): has_sword | has_kyuli_ray | (has_algus_meteor & chalice_on_easy), (R.CAVES_UPPER, R.CAVES_LOWER): HasSwitch(Switch.CAVES_SKELETONS, otherwise=True), (R.CAVES_LOWER, R.CAVES_UPPER): HasSwitch(Switch.CAVES_SKELETONS), (R.CAVES_LOWER, R.CAVES_ITEM_CHAIN): Has(Eye.RED), @@ -864,8 +702,8 @@ ), (R.CATA_START, R.CATA_CLIMBABLE_ROOT): HasSwitch(Switch.CATA_1ST_ROOM, otherwise=True), (R.CATA_START, R.CAVES_LOWER): HasSwitch(Switch.CAVES_CATA_1, Switch.CAVES_CATA_2, Switch.CAVES_CATA_3), - (R.CATA_CLIMBABLE_ROOT, R.CATA_TOP): And(Has(Eye.RED), HasWhite(WhiteDoor.CATA_TOP, otherwise=True)), - (R.CATA_TOP, R.CATA_CLIMBABLE_ROOT): And(Has(Eye.RED), HasWhite(WhiteDoor.CATA_TOP, otherwise=True)), + (R.CATA_CLIMBABLE_ROOT, R.CATA_TOP): Has(Eye.RED) & HasWhite(WhiteDoor.CATA_TOP, otherwise=True), + (R.CATA_TOP, R.CATA_CLIMBABLE_ROOT): Has(Eye.RED) & HasWhite(WhiteDoor.CATA_TOP, otherwise=True), (R.CATA_TOP, R.CATA_ELEVATOR): HasSwitch(Switch.CATA_ELEVATOR, otherwise=True), (R.CATA_TOP, R.CATA_BOW_CAMPFIRE): HasSwitch(Switch.CATA_TOP, otherwise=True), (R.CATA_ELEVATOR, R.CATA_BOSS): HasElevator(Elevator.CATA_2), @@ -880,31 +718,23 @@ (R.CATA_ELEVATOR, R.CATA_MULTI): HasBlue(BlueDoor.CATA_ORBS, otherwise=True), (R.CATA_ELEVATOR, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.CATA_BOW_CAMPFIRE, R.CATA_TOP): HasSwitch(Switch.CATA_TOP), - (R.CATA_BOW_CAMPFIRE, R.CATA_BOW_CONNECTION): And( - Has(Character.KYULI), - HasBlue(BlueDoor.CATA_SAVE, otherwise=True), - ), - (R.CATA_BOW_CAMPFIRE, R.CATA_EYEBALL_BONES): Or(HasSwitch(Face.CATA_AFTER_BOW), otherwise_bow), - (R.CATA_BOW_CONNECTION, R.CATA_BOW): And( - HasBlue(BlueDoor.CATA_BOW, otherwise=True), - Has(Character.KYULI), - ), + (R.CATA_BOW_CAMPFIRE, R.CATA_BOW_CONNECTION): has_kyuli & HasBlue(BlueDoor.CATA_SAVE, otherwise=True), + (R.CATA_BOW_CAMPFIRE, R.CATA_EYEBALL_BONES): HasSwitch(Face.CATA_AFTER_BOW) | otherwise_bow, + (R.CATA_BOW_CONNECTION, R.CATA_BOW): HasBlue(BlueDoor.CATA_BOW, otherwise=True) & has_kyuli, (R.CATA_BOW_CONNECTION, R.CATA_BOW_CAMPFIRE): HasBlue(BlueDoor.CATA_SAVE, otherwise=True), (R.CATA_BOW_CONNECTION, R.CATA_VERTICAL_SHORTCUT): HasSwitch(Switch.CATA_VERTICAL_SHORTCUT), - (R.CATA_VERTICAL_SHORTCUT, R.CATA_BOW_CONNECTION): And( - HasSwitch(Switch.CATA_VERTICAL_SHORTCUT, otherwise=True), - Or(HasSwitch(Switch.CATA_MID_SHORTCUT, otherwise=True), HasAll(Character.KYULI, KeyItem.ICARUS)), + (R.CATA_VERTICAL_SHORTCUT, R.CATA_BOW_CONNECTION): ( + HasSwitch(Switch.CATA_VERTICAL_SHORTCUT, otherwise=True) + & (HasSwitch(Switch.CATA_MID_SHORTCUT, otherwise=True) | (has_kyuli & Has(KeyItem.ICARUS))) ), (R.CATA_EYEBALL_BONES, R.CATA_SNAKE_MUSHROOMS): Has(Eye.RED), - (R.CATA_SNAKE_MUSHROOMS, R.CATA_DEV_ROOM_CONNECTION): HasAll(KeyItem.CLAW, KeyItem.BELL, Character.ZEEK), + (R.CATA_SNAKE_MUSHROOMS, R.CATA_DEV_ROOM_CONNECTION): has_claw & Has(KeyItem.BELL) & has_zeek, (R.CATA_SNAKE_MUSHROOMS, R.CATA_EYEBALL_BONES): Has(Eye.RED), - (R.CATA_SNAKE_MUSHROOMS, R.CATA_DOUBLE_SWITCH): And( - HasSwitch(Switch.CATA_CLAW_2, otherwise=True), - Or(Has(KeyItem.CLAW), HasAll(Character.KYULI, Character.ZEEK, KeyItem.BELL)), + (R.CATA_SNAKE_MUSHROOMS, R.CATA_DOUBLE_SWITCH): ( + HasSwitch(Switch.CATA_CLAW_2, otherwise=True) & (has_claw | (has_kyuli & has_zeek & Has(KeyItem.BELL))) ), - (R.CATA_DEV_ROOM_CONNECTION, R.CATA_DEV_ROOM): Or( - HasRed(RedDoor.DEV_ROOM), - And(HasAll(Character.ZEEK, Character.KYULI), CanReachRegion(R.GT_BOSS), options=red_off), + (R.CATA_DEV_ROOM_CONNECTION, R.CATA_DEV_ROOM): ( + HasRed(RedDoor.DEV_ROOM) | And(has_zeek & has_kyuli & CanReachRegion(R.GT_BOSS), options=red_off) ), (R.CATA_DOUBLE_SWITCH, R.CATA_SNAKE_MUSHROOMS): HasSwitch(Switch.CATA_CLAW_2), (R.CATA_DOUBLE_SWITCH, R.CATA_ROOTS_CAMPFIRE): HasSwitch( @@ -913,43 +743,36 @@ otherwise=True, ), (R.CATA_ROOTS_CAMPFIRE, R.CATA_DOUBLE_SWITCH): HasSwitch(Switch.CATA_WATER_1, Switch.CATA_WATER_2), - (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_ROOTS_CAMPFIRE): Has(KeyItem.CLAW), + (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_ROOTS_CAMPFIRE): has_claw, (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_BLUE_EYE_DOOR): Has(Eye.BLUE), - (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_ABOVE_ROOTS): Has(KeyItem.CLAW), - (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_POISON_ROOTS): And( - HasBlue(BlueDoor.CATA_ROOTS, otherwise=True), - Has(Character.KYULI), - ), + (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_ABOVE_ROOTS): has_claw, + (R.CATA_BELOW_ROOTS_CAMPFIRE, R.CATA_POISON_ROOTS): HasBlue(BlueDoor.CATA_ROOTS, otherwise=True) & has_kyuli, (R.CATA_BLUE_EYE_DOOR, R.CATA_BELOW_ROOTS_CAMPFIRE): Has(Eye.BLUE), (R.CATA_BLUE_EYE_DOOR, R.CATA_FLAMES_FORK): HasWhite(WhiteDoor.CATA_BLUE, otherwise=True), - (R.CATA_FLAMES_FORK, R.CATA_VERTICAL_SHORTCUT): Or( - HasSwitch(Switch.CATA_SHORTCUT_ACCESS, Switch.CATA_AFTER_BLUE_DOOR, otherwise=True), - HardLogic(Has(KeyItem.CLAW)), + (R.CATA_FLAMES_FORK, R.CATA_VERTICAL_SHORTCUT): ( + HasSwitch(Switch.CATA_SHORTCUT_ACCESS, Switch.CATA_AFTER_BLUE_DOOR, otherwise=True) | HardLogic(has_claw) ), - (R.CATA_FLAMES_FORK, R.CATA_BLUE_EYE_DOOR): Or( - HasWhite(WhiteDoor.CATA_BLUE, otherwise=True), - HasSwitch(Switch.CATA_SHORTCUT_ACCESS, otherwise=True), + (R.CATA_FLAMES_FORK, R.CATA_BLUE_EYE_DOOR): ( + HasWhite(WhiteDoor.CATA_BLUE, otherwise=True) | HasSwitch(Switch.CATA_SHORTCUT_ACCESS, otherwise=True) ), (R.CATA_FLAMES_FORK, R.CATA_FLAMES): HasSwitch(Switch.CATA_FLAMES_2, otherwise=True), (R.CATA_FLAMES_FORK, R.CATA_CENTAUR): HasSwitch(Switch.CATA_LADDER_BLOCKS, otherwise=True), - (R.CATA_CENTAUR, R.CATA_4_FACES): Has(KeyItem.CLAW), + (R.CATA_CENTAUR, R.CATA_4_FACES): has_claw, (R.CATA_CENTAUR, R.CATA_FLAMES_FORK): HasSwitch(Switch.CATA_LADDER_BLOCKS), (R.CATA_CENTAUR, R.CATA_BOSS): HasSwitch(Face.CATA_CAMPFIRE), - (R.CATA_4_FACES, R.CATA_DOUBLE_DOOR): Or(HasSwitch(Face.CATA_X4), otherwise_bow), + (R.CATA_4_FACES, R.CATA_DOUBLE_DOOR): HasSwitch(Face.CATA_X4) | otherwise_bow, (R.CATA_DOUBLE_DOOR, R.CATA_4_FACES): HasSwitch(Face.CATA_X4), - (R.CATA_DOUBLE_DOOR, R.CATA_VOID_R): And( - Has(KeyItem.BELL), - can_kill_ghosts, - Or(HasSwitch(Face.CATA_DOUBLE_DOOR), otherwise_bow), + (R.CATA_DOUBLE_DOOR, R.CATA_VOID_R): ( + Has(KeyItem.BELL) & can_kill_ghosts & (HasSwitch(Face.CATA_DOUBLE_DOOR) | otherwise_bow) ), (R.CATA_VOID_R, R.CATA_VOID_L): Has(KeyItem.VOID), (R.CATA_VOID_L, R.CATA_VOID_R): Has(KeyItem.VOID), - (R.CATA_VOID_L, R.CATA_BOSS): And(HasWhite(WhiteDoor.CATA_PRISON, otherwise=True), Has(Character.KYULI)), + (R.CATA_VOID_L, R.CATA_BOSS): HasWhite(WhiteDoor.CATA_PRISON, otherwise=True) & has_kyuli, (R.CATA_BOSS, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), (R.CATA_BOSS, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), - (R.CATA_BOSS, R.CATA_CENTAUR): Or(HasSwitch(Face.CATA_CAMPFIRE), otherwise_bow), + (R.CATA_BOSS, R.CATA_CENTAUR): HasSwitch(Face.CATA_CAMPFIRE) | otherwise_bow, (R.CATA_BOSS, R.CATA_VOID_L): HasWhite(WhiteDoor.CATA_PRISON, otherwise=True), - (R.CATA_BOSS, R.TR_START): Or(HasElevator(Elevator.TR), HasSwitch(Switch.TR_ELEVATOR, otherwise=True)), + (R.CATA_BOSS, R.TR_START): HasElevator(Elevator.TR) | HasSwitch(Switch.TR_ELEVATOR, otherwise=True), (R.CATA_BOSS, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.CATA_BOSS, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), (R.CATA_BOSS, R.APEX): elevator_apex, @@ -957,202 +780,175 @@ (R.CATA_BOSS, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.CATA_BOSS, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.TR_START, R.CATA_ELEVATOR): HasElevator(Elevator.CATA_1), - (R.TR_START, R.CATA_BOSS): Or( - HasElevator(Elevator.CATA_2), - And(HasSwitch(Switch.TR_ELEVATOR), can_extra_height), - ), + (R.TR_START, R.CATA_BOSS): HasElevator(Elevator.CATA_2) | (HasSwitch(Switch.TR_ELEVATOR) & can_extra_height), (R.TR_START, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), (R.TR_START, R.HOTP_BOSS): HasElevator(Elevator.ROA_1), (R.TR_START, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), - (R.TR_START, R.TR_LEFT): And( - HasBlue(BlueDoor.TR, otherwise=True), - Or(HasRed(RedDoor.TR), And(Has(KeyItem.CLAW), CanReachRegion(R.CATA_BOSS), options=red_off)), + (R.TR_START, R.TR_LEFT): ( + HasBlue(BlueDoor.TR, otherwise=True) + & (HasRed(RedDoor.TR) | And(has_claw & CanReachRegion(R.CATA_BOSS), options=red_off)) ), (R.TR_START, R.APEX): elevator_apex, (R.TR_START, R.GT_BOSS): HasElevator(Elevator.GT_2), (R.TR_START, R.MECH_ZEEK_CONNECTION): HasElevator(Elevator.MECH_1), (R.TR_START, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.TR_START, R.TR_BRAM): Has(Eye.BLUE), - (R.TR_LEFT, R.TR_TOP_RIGHT): HasAll(KeyItem.STAR, KeyItem.BELL), - (R.TR_LEFT, R.TR_BOTTOM_LEFT): And(Has(KeyItem.BELL), can_kill_ghosts), + (R.TR_LEFT, R.TR_TOP_RIGHT): has_star & Has(KeyItem.BELL), + (R.TR_LEFT, R.TR_BOTTOM_LEFT): Has(KeyItem.BELL) & can_kill_ghosts, (R.TR_BOTTOM_LEFT, R.TR_BOTTOM): Has(Eye.BLUE), - (R.TR_TOP_RIGHT, R.TR_GOLD): And( - HasAll(Character.ZEEK, KeyItem.BELL), - Or(HasAny(Character.KYULI, KeyItem.BLOCK), can_uppies), - ), - (R.TR_TOP_RIGHT, R.TR_MIDDLE_RIGHT): Or( - HasSwitch(Crystal.TR_GOLD), - And(HasAll(KeyItem.BELL, KeyItem.CLAW), can_crystal, options=switch_off), + (R.TR_TOP_RIGHT, R.TR_GOLD): has_zeek & Has(KeyItem.BELL) & (has_kyuli | has_block | can_uppies), + (R.TR_TOP_RIGHT, R.TR_MIDDLE_RIGHT): ( + HasSwitch(Crystal.TR_GOLD) | And(Has(KeyItem.BELL) & has_claw & can_crystal, options=switch_off) ), (R.TR_MIDDLE_RIGHT, R.TR_DARK_ARIAS): Has(Eye.GREEN), (R.TR_MIDDLE_RIGHT, R.TR_BOTTOM): HasSwitch(Switch.TR_BOTTOM, otherwise=True), (R.TR_BOTTOM, R.TR_BOTTOM_LEFT): Has(Eye.BLUE), - (R.CD_START, R.CD_2): Or(HasSwitch(Switch.CD_1, otherwise=True), HasSwitch(Crystal.CD_BACKTRACK)), - (R.CD_START, R.CD_BOSS): And(CanReachRegion(R.CD_ARIAS_ROUTE), CanReachRegion(R.CD_TOP)), + (R.CD_START, R.CD_2): HasSwitch(Switch.CD_1, otherwise=True) | HasSwitch(Crystal.CD_BACKTRACK), + (R.CD_START, R.CD_BOSS): CanReachRegion(R.CD_ARIAS_ROUTE) & CanReachRegion(R.CD_TOP), (R.CD_3, R.CD_MIDDLE): HasSwitch(Switch.CD_3, otherwise=True), (R.CD_MIDDLE, R.CD_KYULI_ROUTE): HasSwitch(Switch.CD_CAMPFIRE, otherwise=True), - (R.CD_MIDDLE, R.CD_ARIAS_ROUTE): Has(Character.ARIAS), - (R.CD_KYULI_ROUTE, R.CD_CAMPFIRE_3): Has(Character.KYULI), - (R.CD_CAMPFIRE_3, R.CD_ARENA): Or(HasSwitch(Crystal.CD_CAMPFIRE), otherwise_crystal), - (R.CD_STEPS, R.CD_TOP): Or(HasSwitch(Crystal.CD_STEPS), otherwise_crystal), - (R.CATH_START, R.CATH_START_LEFT): And( - Or( - HasSwitch(Crystal.CATH_1ST_ROOM), - And(can_crystal, CanReachRegion(R.CATH_START_TOP_LEFT), options=switch_off), - ), - Has(KeyItem.CLAW), + (R.CD_MIDDLE, R.CD_ARIAS_ROUTE): has_arias, + (R.CD_KYULI_ROUTE, R.CD_CAMPFIRE_3): has_kyuli, + (R.CD_CAMPFIRE_3, R.CD_ARENA): HasSwitch(Crystal.CD_CAMPFIRE) | otherwise_crystal, + (R.CD_STEPS, R.CD_TOP): HasSwitch(Crystal.CD_STEPS) | otherwise_crystal, + (R.CATH_START, R.CATH_START_LEFT): ( + ( + HasSwitch(Crystal.CATH_1ST_ROOM) + | And(can_crystal & CanReachRegion(R.CATH_START_TOP_LEFT), options=switch_off) + ) + & has_claw ), (R.CATH_START_RIGHT, R.CATH_START_TOP_LEFT): HasSwitch(Switch.CATH_BOTTOM, otherwise=True), (R.CATH_START_TOP_LEFT, R.CATH_START_LEFT): HasSwitch(Face.CATH_L), - (R.CATH_START_LEFT, R.CATH_TP): Or(HasSwitch(Face.CATH_R), otherwise_bow), - (R.CATH_LEFT_SHAFT, R.CATH_SHAFT_ACCESS): And(HasSwitch(Crystal.CATH_SHAFT_ACCESS), Has(KeyItem.CLAW)), - (R.CATH_LEFT_SHAFT, R.CATH_UNDER_CAMPFIRE): Or(HasSwitch(Crystal.CATH_SHAFT), otherwise_crystal), - (R.CATH_UNDER_CAMPFIRE, R.CATH_CAMPFIRE_1): HasAll(Character.ZEEK, KeyItem.BELL), - (R.CATH_CAMPFIRE_1, R.CATH_SHAFT_ACCESS): Has(Character.KYULI), + (R.CATH_START_LEFT, R.CATH_TP): HasSwitch(Face.CATH_R) | otherwise_bow, + (R.CATH_LEFT_SHAFT, R.CATH_SHAFT_ACCESS): HasSwitch(Crystal.CATH_SHAFT_ACCESS) & has_claw, + (R.CATH_LEFT_SHAFT, R.CATH_UNDER_CAMPFIRE): HasSwitch(Crystal.CATH_SHAFT) | otherwise_crystal, + (R.CATH_UNDER_CAMPFIRE, R.CATH_CAMPFIRE_1): has_zeek & Has(KeyItem.BELL), + (R.CATH_CAMPFIRE_1, R.CATH_SHAFT_ACCESS): has_kyuli, (R.CATH_SHAFT_ACCESS, R.CATH_ORB_ROOM): HasSwitch(Switch.CATH_BESIDE_SHAFT, otherwise=True), - (R.CATH_ORB_ROOM, R.CATH_GOLD_BLOCK): Or( - HasSwitch(Crystal.CATH_ORBS), - And(can_crystal, Has(KeyItem.BELL), options=switch_off), + (R.CATH_ORB_ROOM, R.CATH_GOLD_BLOCK): ( + HasSwitch(Crystal.CATH_ORBS) | And(can_crystal & Has(KeyItem.BELL), options=switch_off) ), - (R.CATH_RIGHT_SHAFT_CONNECTION, R.CATH_RIGHT_SHAFT): HasAll(KeyItem.BELL, Character.ZEEK, KeyItem.BOW), - (R.CATH_RIGHT_SHAFT, R.CATH_TOP): Has(KeyItem.CLAW), - (R.CATH_TOP, R.CATH_UPPER_SPIKE_PIT): Or( - HasSwitch(Crystal.CATH_SPIKE_PIT), - otherwise_crystal, - HardLogic(HasAll(KeyItem.CLOAK, KeyItem.BLOCK, KeyItem.BELL)), + (R.CATH_RIGHT_SHAFT_CONNECTION, R.CATH_RIGHT_SHAFT): Has(KeyItem.BELL) & has_zeek & has_bow, + (R.CATH_RIGHT_SHAFT, R.CATH_TOP): has_claw, + (R.CATH_TOP, R.CATH_UPPER_SPIKE_PIT): ( + HasSwitch(Crystal.CATH_SPIKE_PIT) | otherwise_crystal | HardLogic(has_cloak & has_block & Has(KeyItem.BELL)) ), (R.CATH_TOP, R.CATH_CAMPFIRE_2): HasSwitch(Switch.CATH_TOP_CAMPFIRE, otherwise=True), - (R.SP_START, R.SP_STAR_END): HasAll(KeyItem.BLOCK, KeyItem.BELL, KeyItem.CLAW), - (R.SP_START, R.SP_CAMPFIRE_1): Or(HasSwitch(Crystal.SP_BLOCKS), otherwise_crystal), + (R.SP_START, R.SP_STAR_END): has_block & Has(KeyItem.BELL) & has_claw, + (R.SP_START, R.SP_CAMPFIRE_1): HasSwitch(Crystal.SP_BLOCKS) | otherwise_crystal, (R.SP_CAMPFIRE_1, R.SP_HEARTS): HasSwitch(Switch.SP_BUBBLES, otherwise=True), (R.SP_HEARTS, R.SP_CAMPFIRE_1): HasSwitch(Switch.SP_BUBBLES), - (R.SP_HEARTS, R.SP_ORBS): HasAll(KeyItem.STAR, KeyItem.BELL, Character.KYULI), + (R.SP_HEARTS, R.SP_ORBS): has_star & Has(KeyItem.BELL) & has_kyuli, (R.SP_HEARTS, R.SP_FROG): HasSwitch(Switch.SP_DOUBLE_DOORS, otherwise=True), - (R.SP_PAINTING, R.SP_HEARTS): And(HasAll(KeyItem.BELL, ShopUpgrade.ALGUS_METEOR), chalice_on_easy), - (R.SP_PAINTING, R.SP_SHAFT): And(Has(KeyItem.CLAW), HasBlue(BlueDoor.SP, otherwise=True)), + (R.SP_PAINTING, R.SP_HEARTS): Has(KeyItem.BELL) & has_algus_meteor & chalice_on_easy, + (R.SP_PAINTING, R.SP_SHAFT): has_claw & HasBlue(BlueDoor.SP, otherwise=True), (R.SP_SHAFT, R.SP_PAINTING): HasBlue(BlueDoor.SP, otherwise=True), - (R.SP_SHAFT, R.SP_STAR): And( - HasAll(KeyItem.CLAW, KeyItem.BELL), - Or(HasSwitch(Crystal.SP_STAR), otherwise_crystal), - ), - (R.SP_STAR, R.SP_SHAFT): And( - HasAll(KeyItem.BELL, ShopUpgrade.ALGUS_METEOR), - chalice_on_easy, - HasSwitch(Crystal.SP_STAR), - ), - (R.SP_STAR, R.SP_STAR_CONNECTION): Has(KeyItem.STAR), - (R.SP_STAR_CONNECTION, R.SP_STAR): Has(KeyItem.STAR), - (R.SP_STAR_CONNECTION, R.SP_STAR_END): And( - Has(KeyItem.STAR), - Or(HasSwitch(Switch.SP_AFTER_STAR), Has(Character.ARIAS, options=switch_off)), - ), - (R.SP_STAR_END, R.SP_STAR_CONNECTION): And(Has(KeyItem.STAR), HasSwitch(Switch.SP_AFTER_STAR)), + (R.SP_SHAFT, R.SP_STAR): has_claw & Has(KeyItem.BELL) & (HasSwitch(Crystal.SP_STAR) | otherwise_crystal), + (R.SP_STAR, R.SP_SHAFT): Has(KeyItem.BELL) & has_algus_meteor & chalice_on_easy & HasSwitch(Crystal.SP_STAR), + (R.SP_STAR, R.SP_STAR_CONNECTION): has_star, + (R.SP_STAR_CONNECTION, R.SP_STAR): has_star, + (R.SP_STAR_CONNECTION, R.SP_STAR_END): ( + has_star & (HasSwitch(Switch.SP_AFTER_STAR) | And(has_arias, options=switch_off)) + ), + (R.SP_STAR_END, R.SP_STAR_CONNECTION): has_star & HasSwitch(Switch.SP_AFTER_STAR), } MAIN_LOCATION_RULES: dict[L, Rule["AstalonWorld"]] = { - L.GT_GORGONHEART: Or( - HasSwitch(Switch.GT_GH, otherwise=True), - HasAny(Character.KYULI, KeyItem.ICARUS, KeyItem.BLOCK, KeyItem.CLOAK, KeyItem.BOOTS), + L.GT_GORGONHEART: ( + HasSwitch(Switch.GT_GH, otherwise=True) | has_kyuli | has_boots | has_block | has_cloak | Has(KeyItem.ICARUS) ), L.GT_ANCIENTS_RING: Has(Eye.RED), - L.GT_BANISH: And( - CanReachRegion(R.GT_BOTTOM), - CanReachRegion(R.GT_ASCENDANT_KEY), - CanReachRegion(R.GT_BUTT), - HasAny(Character.ALGUS, Character.KYULI, Character.BRAM, Character.ZEEK, KeyItem.SWORD), + L.GT_BANISH: ( + CanReachRegion(R.GT_BOTTOM) + & CanReachRegion(R.GT_ASCENDANT_KEY) + & CanReachRegion(R.GT_BUTT) + & (has_algus | has_kyuli | has_bram | has_zeek | has_sword) ), - L.HOTP_BELL: Or(HasSwitch(Switch.HOTP_BELL, otherwise=True), Has(Character.KYULI), can_combo_height), + L.HOTP_BELL: HasSwitch(Switch.HOTP_BELL, otherwise=True) | has_kyuli | can_combo_height, L.HOTP_CLAW: can_extra_height, - L.HOTP_MAIDEN_RING: Or(HasSwitch(Crystal.HOTP_MAIDEN_1, Crystal.HOTP_MAIDEN_2), otherwise_crystal), - L.TR_ADORNED_KEY: Or( - HasSwitch(Switch.TR_ADORNED_L, Switch.TR_ADORNED_M, Switch.TR_ADORNED_R), - And( - HasAll(KeyItem.CLAW, Eye.RED, Character.ZEEK, KeyItem.BELL), - CanReachRegion(R.TR_BOTTOM), - CanReachRegion(R.TR_LEFT), - CanReachRegion(R.TR_DARK_ARIAS), + L.HOTP_MAIDEN_RING: HasSwitch(Crystal.HOTP_MAIDEN_1, Crystal.HOTP_MAIDEN_2) | otherwise_crystal, + L.TR_ADORNED_KEY: ( + HasSwitch(Switch.TR_ADORNED_L, Switch.TR_ADORNED_M, Switch.TR_ADORNED_R) + | And( + has_claw + & has_zeek + & HasAll(Eye.RED, KeyItem.BELL) + & CanReachRegion(R.TR_BOTTOM) + & CanReachRegion(R.TR_LEFT) + & CanReachRegion(R.TR_DARK_ARIAS), options=switch_off, - ), + ) ), - L.CATH_BLOCK: Or(HasSwitch(Crystal.CATH_TOP_L, Crystal.CATH_TOP_R), otherwise_crystal), + L.CATH_BLOCK: HasSwitch(Crystal.CATH_TOP_L, Crystal.CATH_TOP_R) | otherwise_crystal, L.MECH_ZEEK: Has(KeyItem.CROWN), - L.MECH_ATTACK_VOLANTIS: Has(KeyItem.CLAW), - L.MECH_ATTACK_STAR: Has(KeyItem.STAR), - L.ROA_ATTACK: And(HasAll(KeyItem.STAR, KeyItem.BELL), can_extra_height), + L.MECH_ATTACK_VOLANTIS: has_claw, + L.MECH_ATTACK_STAR: has_star, + L.ROA_ATTACK: has_star & Has(KeyItem.BELL) & can_extra_height, L.CAVES_ATTACK_RED: Has(Eye.RED), L.CAVES_ATTACK_BLUE: HasAll(Eye.RED, Eye.BLUE), - L.CAVES_ATTACK_GREEN: And(HasAll(Eye.RED, Eye.BLUE), HasAny(Eye.GREEN, KeyItem.STAR)), - L.CD_ATTACK: Or( - HasSwitch(Switch.CD_TOP, otherwise=True), - HasAll(KeyItem.BLOCK, KeyItem.BELL, Character.KYULI), - ), - L.GT_HP_1_RING: Or( - Has(KeyItem.STAR), - And(CanReachRegion(R.GT_UPPER_PATH), HasBlue(BlueDoor.GT_RING, otherwise=True)), - ), - L.GT_HP_5_KEY: Has(KeyItem.CLAW), + L.CAVES_ATTACK_GREEN: HasAll(Eye.RED, Eye.BLUE) & (Has(Eye.GREEN) | has_star), + L.CD_ATTACK: HasSwitch(Switch.CD_TOP, otherwise=True) | (has_block & Has(KeyItem.BELL) & has_kyuli), + L.GT_HP_1_RING: has_star | (CanReachRegion(R.GT_UPPER_PATH) & HasBlue(BlueDoor.GT_RING, otherwise=True)), + L.GT_HP_5_KEY: has_claw, L.MECH_HP_1_SWITCH: HasSwitch(Switch.MECH_INVISIBLE, otherwise=True), - L.MECH_HP_3_CLAW: Has(KeyItem.CLAW), - L.HOTP_HP_2_GAUNTLET: HasAll(KeyItem.CLAW, Character.ZEEK, KeyItem.BELL), - L.HOTP_HP_5_OLD_MAN: And( - Has(KeyItem.CLAW), - Or(And(Has(KeyItem.BELL), can_kill_ghosts), Has(KeyItem.CHALICE)), - HasSwitch(Switch.HOTP_ABOVE_OLD_MAN, otherwise=True), - ), - L.HOTP_HP_5_START: And(Has(KeyItem.CLAW), HasBlue(BlueDoor.HOTP_START, otherwise=True)), - L.ROA_HP_2_RIGHT: And( - HasAny(KeyItem.GAUNTLET, KeyItem.CHALICE, KeyItem.STAR), - HasAll(KeyItem.BELL, Character.KYULI), - Or(HasSwitch(Crystal.ROA_BRANCH_L, Crystal.ROA_BRANCH_R), otherwise_crystal), - ), - L.ROA_HP_5_SOLARIA: Has(Character.KYULI), + L.MECH_HP_3_CLAW: has_claw, + L.HOTP_HP_2_GAUNTLET: has_claw & has_zeek & Has(KeyItem.BELL), + L.HOTP_HP_5_OLD_MAN: ( + has_claw + & ((Has(KeyItem.BELL) & can_kill_ghosts) | Has(KeyItem.CHALICE)) + & HasSwitch(Switch.HOTP_ABOVE_OLD_MAN, otherwise=True) + ), + L.HOTP_HP_5_START: has_claw & HasBlue(BlueDoor.HOTP_START, otherwise=True), + L.ROA_HP_2_RIGHT: ( + (has_gauntlet | Has(KeyItem.CHALICE) | has_star) + & Has(KeyItem.BELL) + & has_kyuli + & (HasSwitch(Crystal.ROA_BRANCH_L, Crystal.ROA_BRANCH_R) | otherwise_crystal) + ), + L.ROA_HP_5_SOLARIA: has_kyuli, L.APEX_HP_1_CHALICE: HasBlue(BlueDoor.APEX, otherwise=True), - L.APEX_HP_5_HEART: HasAny(Character.KYULI, KeyItem.BLOCK), - L.CAVES_HP_1_START: Or(Has(KeyItem.CHALICE), HasSwitch(Face.CAVES_1ST_ROOM), otherwise_bow), - L.CATA_HP_1_ABOVE_POISON: And( - Has(Character.KYULI), - Or( - HasSwitch(Crystal.CATA_POISON_ROOTS), - And(can_crystal, Has(KeyItem.BELL), options=switch_off), - HardLogic(HasAll(KeyItem.ICARUS, KeyItem.CLAW)), - ), - ), - L.CATA_HP_2_GEMINI_BOTTOM: And(Has(Character.KYULI), Or(HasSwitch(Face.CATA_BOTTOM), otherwise_bow)), - L.CATA_HP_2_GEMINI_TOP: Has(Character.KYULI), - L.CATA_HP_2_ABOVE_GEMINI: And( - Or(Has(KeyItem.CLAW), HasAll(KeyItem.BLOCK, KeyItem.BELL)), - Or(HasAll(KeyItem.GAUNTLET, KeyItem.BELL), Has(KeyItem.CHALICE)), - ), - L.CAVES_HP_5_CHAIN: HasAll(Eye.RED, Eye.BLUE, KeyItem.STAR, KeyItem.CLAW, KeyItem.BELL), - L.CD_HP_1: Or( - HasSwitch(Switch.CD_TOP, otherwise=True), - HasAll(KeyItem.BLOCK, KeyItem.BELL, Character.KYULI), - ), - L.CATH_HP_1_TOP_LEFT: HasAny(KeyItem.CLOAK, KeyItem.ICARUS), - L.CATH_HP_1_TOP_RIGHT: HasAny(KeyItem.CLOAK, KeyItem.ICARUS), - L.CATH_HP_2_CLAW: Has(KeyItem.CLAW), - L.CATH_HP_5_BELL: HasAny(Character.KYULI, KeyItem.BLOCK, KeyItem.ICARUS, KeyItem.CLOAK), + L.APEX_HP_5_HEART: has_kyuli | has_block, + L.CAVES_HP_1_START: Has(KeyItem.CHALICE) | HasSwitch(Face.CAVES_1ST_ROOM) | otherwise_bow, + L.CATA_HP_1_ABOVE_POISON: ( + has_kyuli + & ( + HasSwitch(Crystal.CATA_POISON_ROOTS) + | And(can_crystal & Has(KeyItem.BELL), options=switch_off) + | HardLogic(Has(KeyItem.ICARUS) & has_claw) + ) + ), + L.CATA_HP_2_GEMINI_BOTTOM: has_kyuli & (HasSwitch(Face.CATA_BOTTOM) | otherwise_bow), + L.CATA_HP_2_GEMINI_TOP: has_kyuli, + L.CATA_HP_2_ABOVE_GEMINI: ( + (has_claw | (has_block & Has(KeyItem.BELL))) & ((has_gauntlet & Has(KeyItem.BELL)) | Has(KeyItem.CHALICE)) + ), + L.CAVES_HP_5_CHAIN: HasAll(Eye.RED, Eye.BLUE, KeyItem.BELL) & has_star & has_claw, + L.CD_HP_1: HasSwitch(Switch.CD_TOP, otherwise=True) | (has_block & Has(KeyItem.BELL) & has_kyuli), + L.CATH_HP_1_TOP_LEFT: has_cloak | Has(KeyItem.ICARUS), + L.CATH_HP_1_TOP_RIGHT: has_cloak | Has(KeyItem.ICARUS), + L.CATH_HP_2_CLAW: has_claw, + L.CATH_HP_5_BELL: has_kyuli | has_block | Has(KeyItem.ICARUS) | has_cloak, L.MECH_WHITE_KEY_LINUS: HasSwitch(Switch.MECH_LOWER_KEY, otherwise=True), - L.MECH_WHITE_KEY_TOP: And(Or(HasSwitch(Crystal.MECH_TOP), otherwise_crystal), can_extra_height), + L.MECH_WHITE_KEY_TOP: (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal) & can_extra_height, L.ROA_WHITE_KEY_SAVE: HasSwitch(Switch.ROA_WORMS, otherwise=True), - L.CATA_WHITE_KEY_PRISON: Or(can_extra_height, HasAny(KeyItem.CLOAK, KeyItem.ICARUS)), + L.CATA_WHITE_KEY_PRISON: can_extra_height | has_cloak | Has(KeyItem.ICARUS), L.MECH_BLUE_KEY_BLOCKS: HasSwitch(Switch.MECH_KEY_BLOCKS, otherwise=True), - L.MECH_BLUE_KEY_SAVE: Has(KeyItem.CLAW), - L.MECH_BLUE_KEY_POT: Or(Has(Character.KYULI), can_combo_height), - L.HOTP_BLUE_KEY_STATUE: Has(KeyItem.CLAW), - L.HOTP_BLUE_KEY_AMULET: Or(Has(Character.KYULI), can_combo_height), + L.MECH_BLUE_KEY_SAVE: has_claw, + L.MECH_BLUE_KEY_POT: has_kyuli | can_combo_height, + L.HOTP_BLUE_KEY_STATUE: has_claw, + L.HOTP_BLUE_KEY_AMULET: has_kyuli | can_combo_height, L.HOTP_BLUE_KEY_LADDER: can_extra_height, - L.HOTP_BLUE_KEY_MAZE: Or(HasSwitch(Crystal.HOTP_BELOW_PUZZLE), otherwise_crystal), - L.ROA_BLUE_KEY_FACE: Or(HasSwitch(Face.ROA_BLUE_KEY), otherwise_bow), - L.ROA_BLUE_KEY_FLAMES: Or( - HasAll(KeyItem.BLOCK, Character.KYULI, KeyItem.BELL), - CanReachEntrance(R.ROA_FLAMES, R.ROA_ARIAS_BABY_GORGON), + L.HOTP_BLUE_KEY_MAZE: HasSwitch(Crystal.HOTP_BELOW_PUZZLE) | otherwise_crystal, + L.ROA_BLUE_KEY_FACE: HasSwitch(Face.ROA_BLUE_KEY) | otherwise_bow, + L.ROA_BLUE_KEY_FLAMES: ( + (has_block & has_kyuli & Has(KeyItem.BELL)) | CanReachEntrance(R.ROA_FLAMES, R.ROA_ARIAS_BABY_GORGON) ), L.ROA_BLUE_KEY_TOP: can_extra_height, - L.SP_BLUE_KEY_ARIAS: Has(Character.ARIAS), - L.GT_RED_KEY: HasAll(Character.ZEEK, Character.KYULI), - L.ROA_RED_KEY: HasAll(KeyItem.CLOAK, KeyItem.CLAW, KeyItem.BELL), - L.TR_RED_KEY: Has(KeyItem.CLAW), + L.SP_BLUE_KEY_ARIAS: has_arias, + L.GT_RED_KEY: has_zeek & has_kyuli, + L.ROA_RED_KEY: has_cloak & has_claw & Has(KeyItem.BELL), + L.TR_RED_KEY: has_claw, L.SHOP_GIFT: shop_moderate, L.SHOP_KNOWLEDGE: shop_cheap, L.SHOP_MERCY: shop_expensive, @@ -1178,120 +974,107 @@ L.SHOP_BRAM_WHIPLASH: shop_moderate, L.GT_SWITCH_2ND_ROOM: HasWhite(WhiteDoor.GT_START, otherwise=True), L.GT_SWITCH_BUTT_ACCESS: can_extra_height, - L.GT_SWITCH_UPPER_PATH_ACCESS: Or( - HasSwitch(Switch.GT_UPPER_PATH_BLOCKS, otherwise=True), - HasAll(Character.KYULI, KeyItem.BLOCK, Character.ZEEK, KeyItem.BELL), - ), - L.GT_CRYSTAL_ROTA: And( - can_crystal, - Or( - Has(KeyItem.BELL), - And( - CanReachEntrance(R.MECH_BOTTOM_CAMPFIRE, R.GT_UPPER_PATH_CONNECTION), - CanReachEntrance(R.GT_UPPER_PATH_CONNECTION, R.GT_UPPER_PATH), - ), - ), - ), - L.GT_CRYSTAL_OLD_MAN_1: And( - can_crystal, - Or( - Has(KeyItem.BELL), - HasSwitch(Switch.GT_UPPER_ARIAS), - CanReachRegion(R.GT_ARIAS_SWORD_SWITCH, options=switch_off), - ), - ), - L.GT_CRYSTAL_OLD_MAN_2: And( - can_crystal, - HasSwitch(Crystal.GT_OLD_MAN_1, otherwise=True), - Or( - Has(KeyItem.BELL), - HasSwitch(Switch.GT_UPPER_ARIAS), - CanReachRegion(R.GT_ARIAS_SWORD_SWITCH, options=switch_off), - ), - ), - L.MECH_SWITCH_BOOTS_ACCESS: HasAny(Eye.RED, KeyItem.STAR), - L.MECH_SWITCH_UPPER_VOID_DROP: Has(KeyItem.CLAW), - L.MECH_SWITCH_CANNON: Or(HasSwitch(Crystal.MECH_CANNON), otherwise_crystal), - L.MECH_SWITCH_ARIAS: Has(Character.ARIAS), + L.GT_SWITCH_UPPER_PATH_ACCESS: ( + HasSwitch(Switch.GT_UPPER_PATH_BLOCKS, otherwise=True) | (has_kyuli & has_block & has_zeek & Has(KeyItem.BELL)) + ), + L.GT_CRYSTAL_ROTA: ( + can_crystal + & ( + Has(KeyItem.BELL) + | ( + CanReachEntrance(R.MECH_BOTTOM_CAMPFIRE, R.GT_UPPER_PATH_CONNECTION) + & CanReachEntrance(R.GT_UPPER_PATH_CONNECTION, R.GT_UPPER_PATH) + ) + ) + ), + L.GT_CRYSTAL_OLD_MAN_1: ( + can_crystal + & ( + Has(KeyItem.BELL) + | HasSwitch(Switch.GT_UPPER_ARIAS) + | CanReachRegion(R.GT_ARIAS_SWORD_SWITCH, options=switch_off) + ) + ), + L.GT_CRYSTAL_OLD_MAN_2: ( + can_crystal + & HasSwitch(Crystal.GT_OLD_MAN_1, otherwise=True) + & ( + Has(KeyItem.BELL) + | HasSwitch(Switch.GT_UPPER_ARIAS) + | CanReachRegion(R.GT_ARIAS_SWORD_SWITCH, options=switch_off) + ) + ), + L.MECH_SWITCH_BOOTS_ACCESS: Has(Eye.RED) | has_star, + L.MECH_SWITCH_UPPER_VOID_DROP: has_claw, + L.MECH_SWITCH_CANNON: HasSwitch(Crystal.MECH_CANNON) | otherwise_crystal, + L.MECH_SWITCH_ARIAS: has_arias, L.MECH_CRYSTAL_CANNON: can_crystal, L.MECH_CRYSTAL_LINUS: can_crystal, L.MECH_CRYSTAL_LOWER: can_crystal, L.MECH_CRYSTAL_TO_BOSS_3: can_crystal, L.MECH_CRYSTAL_TOP: can_crystal, - L.MECH_CRYSTAL_CLOAK: And(can_crystal, Has(Eye.BLUE)), + L.MECH_CRYSTAL_CLOAK: can_crystal & Has(Eye.BLUE), L.MECH_CRYSTAL_SLIMES: can_crystal, - L.MECH_CRYSTAL_TO_CD: And(can_crystal, Has(Eye.BLUE), HasBlue(BlueDoor.MECH_CD, otherwise=True)), + L.MECH_CRYSTAL_TO_CD: can_crystal & Has(Eye.BLUE) & HasBlue(BlueDoor.MECH_CD, otherwise=True), L.MECH_CRYSTAL_CAMPFIRE: can_crystal, L.MECH_CRYSTAL_1ST_ROOM: can_crystal, L.MECH_CRYSTAL_OLD_MAN: can_crystal, L.MECH_CRYSTAL_TOP_CHAINS: can_crystal, L.MECH_CRYSTAL_BK: can_crystal, - L.MECH_FACE_ABOVE_VOLANTIS: HasAll(KeyItem.BOW, KeyItem.CLAW), - L.HOTP_SWITCH_LOWER_SHORTCUT: Or(HasSwitch(Crystal.HOTP_LOWER), otherwise_crystal), - L.HOTP_SWITCH_TO_CLAW_2: Or( - HasSwitch(Switch.HOTP_TO_CLAW_1, otherwise=True), - And(HasSwitch(Switch.HOTP_TO_CLAW_2, otherwise=True), can_extra_height), - Has(KeyItem.CLAW), - ), - L.HOTP_SWITCH_CLAW_ACCESS: Or(Has(Character.KYULI), can_block_in_wall), - L.HOTP_SWITCH_LEFT_3: Or( - HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True), - And(Has(KeyItem.STAR), CanReachRegion(R.HOTP_START_LEFT)), + L.MECH_FACE_ABOVE_VOLANTIS: has_bow & has_claw, + L.HOTP_SWITCH_LOWER_SHORTCUT: HasSwitch(Crystal.HOTP_LOWER) | otherwise_crystal, + L.HOTP_SWITCH_TO_CLAW_2: ( + HasSwitch(Switch.HOTP_TO_CLAW_1, otherwise=True) + | (HasSwitch(Switch.HOTP_TO_CLAW_2, otherwise=True) & can_extra_height) + | has_claw + ), + L.HOTP_SWITCH_CLAW_ACCESS: has_kyuli | can_block_in_wall, + L.HOTP_SWITCH_LEFT_3: ( + HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True) + | (has_star & CanReachRegion(R.HOTP_START_LEFT)) ), L.HOTP_CRYSTAL_ROCK_ACCESS: can_crystal, L.HOTP_CRYSTAL_BOTTOM: can_crystal, L.HOTP_CRYSTAL_LOWER: can_crystal, L.HOTP_CRYSTAL_AFTER_CLAW: can_crystal, L.HOTP_CRYSTAL_MAIDEN_1: can_crystal, - L.HOTP_CRYSTAL_MAIDEN_2: And( - can_crystal, - Or(HasSwitch(Crystal.HOTP_MAIDEN_1, otherwise=True), Has(Character.KYULI)), - ), + L.HOTP_CRYSTAL_MAIDEN_2: can_crystal & (HasSwitch(Crystal.HOTP_MAIDEN_1, otherwise=True) | has_kyuli), L.HOTP_CRYSTAL_BELL_ACCESS: can_crystal, L.HOTP_CRYSTAL_HEART: can_crystal, L.HOTP_CRYSTAL_BELOW_PUZZLE: can_crystal, - L.HOTP_FACE_OLD_MAN: Has(KeyItem.BOW), - L.ROA_SWITCH_SPIKE_CLIMB: Has(KeyItem.CLAW), - L.ROA_SWITCH_TRIPLE_3: Or(HasSwitch(Crystal.ROA_TRIPLE_2), otherwise_crystal), - L.ROA_CRYSTAL_1ST_ROOM: And(can_crystal, HasAll(Character.KYULI, KeyItem.BELL)), + L.HOTP_FACE_OLD_MAN: has_bow, + L.ROA_SWITCH_SPIKE_CLIMB: has_claw, + L.ROA_SWITCH_TRIPLE_3: HasSwitch(Crystal.ROA_TRIPLE_2) | otherwise_crystal, + L.ROA_CRYSTAL_1ST_ROOM: can_crystal & has_kyuli & Has(KeyItem.BELL), L.ROA_CRYSTAL_BABY_GORGON: can_crystal, - L.ROA_CRYSTAL_LADDER_R: And( - can_crystal_no_whiplash, - Or(Has(KeyItem.BELL), HardLogic(Has(ShopUpgrade.KYULI_RAY))), - ), - L.ROA_CRYSTAL_LADDER_L: And( - can_crystal_no_whiplash, - Or(Has(KeyItem.BELL), HardLogic(Has(ShopUpgrade.KYULI_RAY))), - ), - L.ROA_CRYSTAL_CENTAUR: And(can_crystal, HasAll(KeyItem.BELL, Character.ARIAS)), + L.ROA_CRYSTAL_LADDER_R: can_crystal_no_whiplash & (Has(KeyItem.BELL) | HardLogic(has_kyuli_ray)), + L.ROA_CRYSTAL_LADDER_L: can_crystal_no_whiplash & (Has(KeyItem.BELL) | HardLogic(has_kyuli_ray)), + L.ROA_CRYSTAL_CENTAUR: can_crystal & Has(KeyItem.BELL) & has_arias, L.ROA_CRYSTAL_SPIKE_BALLS: can_crystal, L.ROA_CRYSTAL_SHAFT: can_crystal, - L.ROA_CRYSTAL_BRANCH_R: And(can_crystal, HasAll(Character.KYULI, KeyItem.BELL)), - L.ROA_CRYSTAL_BRANCH_L: And(can_crystal, HasAll(Character.KYULI, KeyItem.BELL)), + L.ROA_CRYSTAL_BRANCH_R: can_crystal & has_kyuli & Has(KeyItem.BELL), + L.ROA_CRYSTAL_BRANCH_L: can_crystal & has_kyuli & Has(KeyItem.BELL), L.ROA_CRYSTAL_3_REAPERS: can_crystal, - L.ROA_CRYSTAL_TRIPLE_2: And(can_crystal, HasSwitch(Switch.ROA_TRIPLE_1, otherwise=True)), - L.ROA_FACE_SPIDERS: Has(KeyItem.BOW), - L.ROA_FACE_BLUE_KEY: Has(KeyItem.BOW), - L.DARK_SWITCH: Has(KeyItem.CLAW), - L.CAVES_FACE_1ST_ROOM: Has(KeyItem.BOW), + L.ROA_CRYSTAL_TRIPLE_2: can_crystal & HasSwitch(Switch.ROA_TRIPLE_1, otherwise=True), + L.ROA_FACE_SPIDERS: has_bow, + L.ROA_FACE_BLUE_KEY: has_bow, + L.DARK_SWITCH: has_claw, + L.CAVES_FACE_1ST_ROOM: has_bow, L.CATA_SWITCH_CLAW_2: HasSwitch(Switch.CATA_CLAW_1, otherwise=True), L.CATA_SWITCH_FLAMES_2: HasSwitch(Switch.CATA_FLAMES_1, otherwise=True), L.CATA_CRYSTAL_POISON_ROOTS: can_crystal, - L.CATA_FACE_AFTER_BOW: Has(KeyItem.BOW), - L.CATA_FACE_BOW: Has(KeyItem.BOW), - L.CATA_FACE_X4: Has(KeyItem.BOW), - L.CATA_FACE_CAMPFIRE: Has(KeyItem.BOW), - L.CATA_FACE_DOUBLE_DOOR: Has(KeyItem.BOW), - L.CATA_FACE_BOTTOM: Has(KeyItem.BOW), - L.TR_SWITCH_ADORNED_L: Has(KeyItem.CLAW), + L.CATA_FACE_AFTER_BOW: has_bow, + L.CATA_FACE_BOW: has_bow, + L.CATA_FACE_X4: has_bow, + L.CATA_FACE_CAMPFIRE: has_bow, + L.CATA_FACE_DOUBLE_DOOR: has_bow, + L.CATA_FACE_BOTTOM: has_bow, + L.TR_SWITCH_ADORNED_L: has_claw, L.TR_SWITCH_ADORNED_M: Has(Eye.RED), - L.TR_SWITCH_ADORNED_R: And( - HasSwitch(Crystal.TR_DARK_ARIAS, otherwise=True), - HasAll(Character.ZEEK, KeyItem.BELL, KeyItem.CLAW), - ), - L.TR_CRYSTAL_GOLD: And(can_crystal, HasAll(KeyItem.BELL, KeyItem.CLAW)), - L.TR_CRYSTAL_DARK_ARIAS: And(can_crystal, HasAll(Character.ZEEK, KeyItem.BELL, KeyItem.CLAW)), - L.CD_SWITCH_1: Or(HasSwitch(Crystal.CD_START), otherwise_crystal), + L.TR_SWITCH_ADORNED_R: HasSwitch(Crystal.TR_DARK_ARIAS, otherwise=True) & has_zeek & Has(KeyItem.BELL) & has_claw, + L.TR_CRYSTAL_GOLD: can_crystal & Has(KeyItem.BELL) & has_claw, + L.TR_CRYSTAL_DARK_ARIAS: can_crystal & has_zeek & Has(KeyItem.BELL) & has_claw, + L.CD_SWITCH_1: HasSwitch(Crystal.CD_START) | otherwise_crystal, L.CD_CRYSTAL_BACKTRACK: can_crystal, L.CD_CRYSTAL_START: can_crystal, L.CD_CRYSTAL_CAMPFIRE: can_crystal, @@ -1303,19 +1086,15 @@ L.CATH_CRYSTAL_TOP_R: can_crystal, L.CATH_CRYSTAL_SHAFT_ACCESS: can_crystal, L.CATH_CRYSTAL_ORBS: can_crystal, - L.CATH_FACE_LEFT: Has(KeyItem.BOW), - L.CATH_FACE_RIGHT: Has(KeyItem.BOW), - L.SP_SWITCH_AFTER_STAR: Has(Character.ARIAS), + L.CATH_FACE_LEFT: has_bow, + L.CATH_FACE_RIGHT: has_bow, + L.SP_SWITCH_AFTER_STAR: has_arias, L.SP_CRYSTAL_BLOCKS: can_crystal, L.SP_CRYSTAL_STAR: can_crystal, - L.ROA_CANDLE_ARENA: Or( - can_extra_height, - Has(ShopUpgrade.BRAM_AXE), - CanReachRegion(R.ROA_FLAMES_CONNECTION), - ), - L.ROA_CANDLE_HIDDEN_4: HasAny(Character.KYULI, ShopUpgrade.BRAM_AXE), - L.ROA_CANDLE_HIDDEN_5: Has(Character.KYULI), - L.CATA_CANDLE_DEV_ROOM: Or(Has(KeyItem.CLAW), HasSwitch(Switch.CATA_DEV_ROOM, otherwise=True)), + L.ROA_CANDLE_ARENA: can_extra_height | has_bram_axe | CanReachRegion(R.ROA_FLAMES_CONNECTION), + L.ROA_CANDLE_HIDDEN_4: has_kyuli | has_bram_axe, + L.ROA_CANDLE_HIDDEN_5: has_kyuli, + L.CATA_CANDLE_DEV_ROOM: has_claw | HasSwitch(Switch.CATA_DEV_ROOM, otherwise=True), L.CATA_CANDLE_PRISON: HasBlue(BlueDoor.CATA_PRISON_RIGHT, otherwise=True), } From bb79dccf4441b60187de7425ac384c51a66f2ab2 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 17 Nov 2025 20:48:16 -0500 Subject: [PATCH 085/135] fix issue with combining rules with different options --- rule_builder.py | 26 ++++++++++++-------------- test/general/test_rule_builder.py | 8 ++++++++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index aece1d2cfc08..df496cb92fe8 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -3,9 +3,9 @@ import operator from collections import defaultdict from collections.abc import Callable, Iterable, Mapping -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, Never, Self, cast -from typing_extensions import Never, Self, TypeVar, dataclass_transform, override +from typing_extensions import TypeVar, dataclass_transform, override from BaseClasses import CollectionState, Entrance, Item, Location, MultiWorld, Region from NetUtils import JSONMessagePart @@ -541,26 +541,24 @@ def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> def __and__(self, other: "Rule[Any]") -> "Rule[TWorld]": """Combines two rules into an And rule""" - if isinstance(self, And): - if isinstance(other, And): - if self.options == other.options: + if self.options == other.options: + if isinstance(self, And): + if isinstance(other, And): return And(*self.children, *other.children, options=self.options) - else: return And(*self.children, other, options=self.options) - elif isinstance(other, And): - return And(self, *other.children, options=other.options) + if isinstance(other, And): + return And(self, *other.children, options=other.options) return And(self, other) def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": """Combines two rules into an Or rule""" - if isinstance(self, Or): - if isinstance(other, Or): - if self.options == other.options: + if self.options == other.options: + if isinstance(self, Or): + if isinstance(other, Or): return Or(*self.children, *other.children, options=self.options) - else: return Or(*self.children, other, options=self.options) - elif isinstance(other, Or): - return Or(self, *other.children, options=other.options) + if isinstance(other, Or): + return Or(self, *other.children, options=self.options) return Or(self, other) def __bool__(self) -> Never: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 3dfde889f5ba..6bed5ec74f96 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -253,6 +253,14 @@ def test_gt_filtering(self) -> None: Has("A") & Has("B") | Has("C") & Has("D"), Or(And(Has("A"), Has("B")), And(Has("C"), Has("D"))), ), + ( + Has("A") | Or(Has("B"), options=[OptionFilter(ToggleOption, 1)]), + Or(Has("A"), Or(Has("B"), options=[OptionFilter(ToggleOption, 1)])), + ), + ( + Has("A") & And(Has("B"), options=[OptionFilter(ToggleOption, 1)]), + And(Has("A"), And(Has("B"), options=[OptionFilter(ToggleOption, 1)])), + ), ) ) class TestComposition(unittest.TestCase): From 393e03c912287fb147025bfaa7b611b31dc04451 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 17 Nov 2025 20:53:33 -0500 Subject: [PATCH 086/135] fix test --- worlds/astalon/test/test_rules.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py index b7c1f2da9dbb..048bd6436125 100644 --- a/worlds/astalon/test/test_rules.py +++ b/worlds/astalon/test/test_rules.py @@ -1,7 +1,8 @@ from rule_builder import And, OptionFilter, Or, True_ -from ..items import BlueDoor, Character, Crystal, KeyItem -from ..logic.custom_rules import Has, HasAll, HasAny, HasBlue, HasSwitch +from ..items import BlueDoor, Crystal +from ..logic.custom_rules import Has, HasAll, HasBlue, HasSwitch +from ..logic.main_campaign import has_arias, has_block, has_bram, has_star from ..options import Difficulty, RandomizeCharacters from .bases import AstalonTestBase @@ -52,15 +53,14 @@ def test_upper_path_rule_easy(self) -> None: HasSwitch(Crystal.GT_ROTA), Or( True_(options=[OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)]), - HasAny( - Character.ARIAS, - Character.BRAM, + Or( + has_arias | has_bram, options=[OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")], ), options=[OptionFilter(Difficulty, Difficulty.option_hard)], ), - And(Has(KeyItem.STAR), HasBlue(BlueDoor.GT_RING, otherwise=True)), - Has(KeyItem.BLOCK), + And(has_star, HasBlue(BlueDoor.GT_RING, otherwise=True)), + has_block, ) expected = Or.Resolved( ( From 59653f02024b126a51acda95429f9d77ebb427d5 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 20 Nov 2025 22:56:05 -0500 Subject: [PATCH 087/135] add convenience functions for filtering --- rule_builder.py | 19 ++++++++++++++++--- test/general/test_rule_builder.py | 5 +++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index df496cb92fe8..98938c68440e 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -561,6 +561,10 @@ def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": return Or(self, *other.children, options=self.options) return Or(self, other) + def __xor__(self, other: Iterable[OptionFilter[Any]]) -> "Rule[TWorld]": + """Convenience operator to filter an existing rule with an option filter""" + return Filter(self, options=other) + def __bool__(self) -> Never: """Safeguard to prevent devs from mistakenly doing `rule1 and rule2` and getting the wrong result""" raise TypeError("Use & or | to combine rules, or use `is not None` for boolean tests") @@ -706,7 +710,7 @@ def __str__(self) -> str: @dataclasses.dataclass(init=False) class NestedRule(Rule[TWorld], game="Archipelago"): - """A rule that takes an iterable of other rules as an argument and does logic based on them""" + """A base rule class that takes an iterable of other rules as an argument and does logic based on them""" children: tuple[Rule[TWorld], ...] """The child rules this rule's logic is based on""" @@ -863,8 +867,8 @@ def __str__(self) -> str: @dataclasses.dataclass() -class Wrapper(Rule[TWorld], game="Archipelago"): - """A rule that wraps another rule to provide extra logic or data""" +class WrapperRule(Rule[TWorld], game="Archipelago"): + """A base rule class that wraps another rule to provide extra logic or data""" child: Rule[TWorld] """The child rule being wrapped""" @@ -952,6 +956,15 @@ def __str__(self) -> str: return f"{self.rule_name}[{self.child}]" +@dataclasses.dataclass() +class Filter(WrapperRule[TWorld], game="Archipelago"): + """A convenience rule to wrap an existing rule with an options filter""" + + @override + def _instantiate(self, world: TWorld) -> Rule.Resolved: + return world.resolve_rule(self.child) + + @dataclasses.dataclass() class Has(Rule[TWorld], game="Archipelago"): """A rule that checks if the player has at least `count` of a given item""" diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 6bed5ec74f96..e49eff0b9757 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -13,6 +13,7 @@ CanReachLocation, CanReachRegion, False_, + Filter, Has, HasAll, HasAllCounts, @@ -261,6 +262,10 @@ def test_gt_filtering(self) -> None: Has("A") & And(Has("B"), options=[OptionFilter(ToggleOption, 1)]), And(Has("A"), And(Has("B"), options=[OptionFilter(ToggleOption, 1)])), ), + ( + (Has("A") | Has("B")) ^ [OptionFilter(ToggleOption, 1)], + Filter(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]), + ), ) ) class TestComposition(unittest.TestCase): From 76e28cd7afeca41efeecb28d5f6896517603c035 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 20 Nov 2025 23:18:44 -0500 Subject: [PATCH 088/135] use an operator with higher precedence --- rule_builder.py | 2 +- test/general/test_rule_builder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 98938c68440e..d6f7a9ab29c1 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -561,7 +561,7 @@ def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": return Or(self, *other.children, options=self.options) return Or(self, other) - def __xor__(self, other: Iterable[OptionFilter[Any]]) -> "Rule[TWorld]": + def __lshift__(self, other: Iterable[OptionFilter[Any]]) -> "Rule[TWorld]": """Convenience operator to filter an existing rule with an option filter""" return Filter(self, options=other) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index e49eff0b9757..073f3518bb75 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -263,7 +263,7 @@ def test_gt_filtering(self) -> None: And(Has("A"), And(Has("B"), options=[OptionFilter(ToggleOption, 1)])), ), ( - (Has("A") | Has("B")) ^ [OptionFilter(ToggleOption, 1)], + (Has("A") | Has("B")) << [OptionFilter(ToggleOption, 1)], Filter(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]), ), ) From a4732db5c76b6b2f4b4f28393bd9bfbf96bc0057 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 20 Nov 2025 23:46:11 -0500 Subject: [PATCH 089/135] name conflicts less with optionfilter --- rule_builder.py | 4 ++-- test/general/test_rule_builder.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index d6f7a9ab29c1..aaa517e8f231 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -563,7 +563,7 @@ def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": def __lshift__(self, other: Iterable[OptionFilter[Any]]) -> "Rule[TWorld]": """Convenience operator to filter an existing rule with an option filter""" - return Filter(self, options=other) + return Filtered(self, options=other) def __bool__(self) -> Never: """Safeguard to prevent devs from mistakenly doing `rule1 and rule2` and getting the wrong result""" @@ -957,7 +957,7 @@ def __str__(self) -> str: @dataclasses.dataclass() -class Filter(WrapperRule[TWorld], game="Archipelago"): +class Filtered(WrapperRule[TWorld], game="Archipelago"): """A convenience rule to wrap an existing rule with an options filter""" @override diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 073f3518bb75..ddacbbe7e1f0 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -13,7 +13,7 @@ CanReachLocation, CanReachRegion, False_, - Filter, + Filtered, Has, HasAll, HasAllCounts, @@ -264,7 +264,7 @@ def test_gt_filtering(self) -> None: ), ( (Has("A") | Has("B")) << [OptionFilter(ToggleOption, 1)], - Filter(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]), + Filtered(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]), ), ) ) From 54bff88c2e4caedb05908039c48d2ab304fee6cb Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 20 Nov 2025 23:47:41 -0500 Subject: [PATCH 090/135] update to use filter helpers --- worlds/astalon/logic/custom_rules.py | 4 +-- worlds/astalon/logic/main_campaign.py | 46 +++++++++++++-------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 62d2b8e0d569..c2149f906bf2 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -232,7 +232,7 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: @dataclasses.dataclass() -class HardLogic(rule_builder.Wrapper["AstalonWorld"], game=GAME_NAME): +class HardLogic(rule_builder.WrapperRule["AstalonWorld"], game=GAME_NAME): @override def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: if world.options.difficulty.value == Difficulty.option_hard: @@ -245,7 +245,7 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: ) return world.false_rule - class Resolved(rule_builder.Wrapper.Resolved): + class Resolved(rule_builder.WrapperRule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child(state) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 726ba5682a9f..46e91fd1b0cf 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from rule_builder import And, OptionFilter, Or, Rule, True_ +from rule_builder import Filtered, OptionFilter, Rule, True_ from ..items import ( BlueDoor, @@ -102,8 +102,8 @@ can_big_magic = HardLogic(has_algus_arcanist & has_banish) can_kill_ghosts = has_banish | has_block | (has_algus_meteor & chalice_on_easy) -otherwise_crystal = And(can_crystal, options=switch_off) -otherwise_bow = And(has_bow, options=switch_off) +otherwise_crystal = can_crystal << switch_off +otherwise_bow = has_bow << switch_off elevator_apex = HasElevator( Elevator.APEX, @@ -168,7 +168,7 @@ ), (R.GT_SPIKE_TUNNEL, R.GT_TOP_RIGHT): HasSwitch(Switch.GT_SPIKE_TUNNEL), (R.GT_SPIKE_TUNNEL, R.GT_SPIKE_TUNNEL_SWITCH): can_extra_height, - (R.GT_SPIKE_TUNNEL_SWITCH, R.GT_BUTT): HardLogic(has_star) | And(has_star & Has(KeyItem.BELL), options=easy), + (R.GT_SPIKE_TUNNEL_SWITCH, R.GT_BUTT): HardLogic(has_star) | Filtered(has_star & Has(KeyItem.BELL), options=easy), (R.GT_BUTT, R.GT_TOP_LEFT): HasSwitch(Switch.GT_BUTT_ACCESS), (R.GT_BUTT, R.GT_SPIKE_TUNNEL_SWITCH): has_star, (R.GT_BUTT, R.GT_BOSS): HasWhite(WhiteDoor.GT_TAUROS) | CanReachRegion(R.GT_TOP_RIGHT, options=white_off), @@ -209,7 +209,7 @@ (HasSwitch(Switch.MECH_CANNON) | otherwise_crystal) & ( HasWhite(WhiteDoor.MECH_2ND) - | And( + | Filtered( CanReachRegion(R.MECH_SWORD_CONNECTION) & HasSwitch(Switch.MECH_LOWER_KEY, otherwise=True), options=white_off, ) @@ -239,7 +239,7 @@ (R.MECH_SWORD_CONNECTION, R.GT_UPPER_ARIAS): has_arias | HasSwitch(Switch.GT_UPPER_ARIAS), (R.MECH_BOOTS_CONNECTION, R.MECH_BOTTOM_CAMPFIRE): HasBlue(BlueDoor.MECH_VOID, otherwise=True), (R.MECH_BOOTS_CONNECTION, R.MECH_BOOTS_LOWER): ( - HasSwitch(Switch.MECH_BOOTS) | Or(Has(Eye.RED) | has_star, options=switch_off) + HasSwitch(Switch.MECH_BOOTS) | Filtered(Has(Eye.RED) | has_star, options=switch_off) ), (R.MECH_BOOTS_LOWER, R.MECH_BOOTS_UPPER): HasSwitch(Switch.MECH_BOOTS_LOWER, otherwise=True) | can_extra_height, (R.MECH_BOTTOM_CAMPFIRE, R.GT_UPPER_PATH_CONNECTION): HasSwitch(Switch.MECH_TO_UPPER_GT, otherwise=True), @@ -335,7 +335,7 @@ (R.MECH_TOP, R.MECH_TP_CONNECTION): ( has_claw | HasWhite(WhiteDoor.MECH_TOP) - | And(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) + | Filtered(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) ), (R.MECH_TOP, R.MECH_CD_ACCESS): ( Has(Eye.BLUE) @@ -345,10 +345,10 @@ (R.MECH_CD_ACCESS, R.CD_START): Has(KeyItem.CYCLOPS), (R.MECH_TOP, R.MECH_TRIPLE_SWITCHES): ( can_crystal - & (HasSwitch(Switch.MECH_ARIAS_CYCLOPS) | And(has_arias, options=switch_off)) + & (HasSwitch(Switch.MECH_ARIAS_CYCLOPS) | (has_arias << switch_off)) & ( HasWhite(WhiteDoor.MECH_TOP) - | And(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) + | Filtered(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) | (has_claw & Has(KeyItem.BELL)) ) ), @@ -513,7 +513,7 @@ HasBlue(BlueDoor.HOTP_MAIDEN, otherwise=True) & (has_sword | (has_kyuli & has_block & Has(KeyItem.BELL))) ), (R.HOTP_BOSS_CAMPFIRE, R.HOTP_TP_PUZZLE): Has(Eye.GREEN), - (R.HOTP_BOSS_CAMPFIRE, R.HOTP_BOSS): HasWhite(WhiteDoor.HOTP_BOSS) | And(has_arias, options=white_off), + (R.HOTP_BOSS_CAMPFIRE, R.HOTP_BOSS): HasWhite(WhiteDoor.HOTP_BOSS) | (has_arias << white_off), (R.HOTP_TP_PUZZLE, R.HOTP_TP_FALL_TOP): has_star | HasSwitch(Switch.HOTP_TP_PUZZLE, otherwise=True), (R.HOTP_TP_FALL_TOP, R.HOTP_FALL_BOTTOM): has_cloak, (R.HOTP_TP_FALL_TOP, R.HOTP_TP_PUZZLE): has_star | HasSwitch(Switch.HOTP_TP_PUZZLE), @@ -535,7 +535,7 @@ (R.HOTP_BOSS, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.ROA_START, R.ROA_WORMS): ( # this should be more complicated - HasSwitch(Crystal.ROA_1ST_ROOM) | And(Has(KeyItem.BELL) & can_crystal, options=switch_off) + HasSwitch(Crystal.ROA_1ST_ROOM) | Filtered(Has(KeyItem.BELL) & can_crystal, options=switch_off) ), (R.ROA_WORMS, R.ROA_START): ( HasSwitch(Switch.ROA_WORMS, otherwise=True) | HasSwitch(Crystal.ROA_1ST_ROOM) | otherwise_crystal @@ -575,7 +575,7 @@ (R.ROA_ARIAS_BABY_GORGON, R.ROA_ARIAS_BABY_GORGON_CONNECTION): has_arias & HasSwitch(Crystal.ROA_BABY_GORGON), (R.ROA_FLAMES_CONNECTION, R.ROA_WORM_CLIMB): HasBlue(BlueDoor.ROA_FLAMES, otherwise=True) & has_claw, (R.ROA_FLAMES_CONNECTION, R.ROA_LEFT_ASCENT): ( - (HasSwitch(Crystal.ROA_LEFT_ASCEND) | And(can_crystal & Has(KeyItem.BELL), options=switch_off)) + (HasSwitch(Crystal.ROA_LEFT_ASCEND) | Filtered(can_crystal & Has(KeyItem.BELL), options=switch_off)) & can_extra_height ), (R.ROA_FLAMES_CONNECTION, R.ROA_ARIAS_BABY_GORGON_CONNECTION): has_star, @@ -603,7 +603,7 @@ (R.ROA_MIDDLE, R.ROA_MIDDLE_LADDER): ( # this could allow more HasSwitch(Crystal.ROA_LADDER_L, Crystal.ROA_LADDER_R) - | And( + | Filtered( can_crystal & CanReachRegion(R.ROA_LEFT_SWITCH) & CanReachRegion(R.ROA_RIGHT_SWITCH_2), options=switch_off, ) @@ -629,7 +629,7 @@ (R.ROA_SPIDERS_2, R.ROA_BLOOD_POT_HALLWAY): HasSwitch(Switch.ROA_SPIDERS, otherwise=True), (R.ROA_SP_CONNECTION, R.SP_START): ( HasRed(RedDoor.SP) - | And(has_cloak & has_claw & Has(KeyItem.BELL) & CanReachRegion(R.ROA_RED_KEY), options=red_off) + | Filtered(has_cloak & has_claw & Has(KeyItem.BELL) & CanReachRegion(R.ROA_RED_KEY), options=red_off) ), # can probably make it without claw (R.ROA_SP_CONNECTION, R.ROA_ELEVATOR): has_claw & HasSwitch(Switch.ROA_DARK_ROOM, otherwise=True), @@ -734,7 +734,7 @@ HasSwitch(Switch.CATA_CLAW_2, otherwise=True) & (has_claw | (has_kyuli & has_zeek & Has(KeyItem.BELL))) ), (R.CATA_DEV_ROOM_CONNECTION, R.CATA_DEV_ROOM): ( - HasRed(RedDoor.DEV_ROOM) | And(has_zeek & has_kyuli & CanReachRegion(R.GT_BOSS), options=red_off) + HasRed(RedDoor.DEV_ROOM) | Filtered(has_zeek & has_kyuli & CanReachRegion(R.GT_BOSS), options=red_off) ), (R.CATA_DOUBLE_SWITCH, R.CATA_SNAKE_MUSHROOMS): HasSwitch(Switch.CATA_CLAW_2), (R.CATA_DOUBLE_SWITCH, R.CATA_ROOTS_CAMPFIRE): HasSwitch( @@ -786,7 +786,7 @@ (R.TR_START, R.ROA_ELEVATOR): HasElevator(Elevator.ROA_2), (R.TR_START, R.TR_LEFT): ( HasBlue(BlueDoor.TR, otherwise=True) - & (HasRed(RedDoor.TR) | And(has_claw & CanReachRegion(R.CATA_BOSS), options=red_off)) + & (HasRed(RedDoor.TR) | Filtered(has_claw & CanReachRegion(R.CATA_BOSS), options=red_off)) ), (R.TR_START, R.APEX): elevator_apex, (R.TR_START, R.GT_BOSS): HasElevator(Elevator.GT_2), @@ -798,7 +798,7 @@ (R.TR_BOTTOM_LEFT, R.TR_BOTTOM): Has(Eye.BLUE), (R.TR_TOP_RIGHT, R.TR_GOLD): has_zeek & Has(KeyItem.BELL) & (has_kyuli | has_block | can_uppies), (R.TR_TOP_RIGHT, R.TR_MIDDLE_RIGHT): ( - HasSwitch(Crystal.TR_GOLD) | And(Has(KeyItem.BELL) & has_claw & can_crystal, options=switch_off) + HasSwitch(Crystal.TR_GOLD) | Filtered(Has(KeyItem.BELL) & has_claw & can_crystal, options=switch_off) ), (R.TR_MIDDLE_RIGHT, R.TR_DARK_ARIAS): Has(Eye.GREEN), (R.TR_MIDDLE_RIGHT, R.TR_BOTTOM): HasSwitch(Switch.TR_BOTTOM, otherwise=True), @@ -814,7 +814,7 @@ (R.CATH_START, R.CATH_START_LEFT): ( ( HasSwitch(Crystal.CATH_1ST_ROOM) - | And(can_crystal & CanReachRegion(R.CATH_START_TOP_LEFT), options=switch_off) + | Filtered(can_crystal & CanReachRegion(R.CATH_START_TOP_LEFT), options=switch_off) ) & has_claw ), @@ -827,7 +827,7 @@ (R.CATH_CAMPFIRE_1, R.CATH_SHAFT_ACCESS): has_kyuli, (R.CATH_SHAFT_ACCESS, R.CATH_ORB_ROOM): HasSwitch(Switch.CATH_BESIDE_SHAFT, otherwise=True), (R.CATH_ORB_ROOM, R.CATH_GOLD_BLOCK): ( - HasSwitch(Crystal.CATH_ORBS) | And(can_crystal & Has(KeyItem.BELL), options=switch_off) + HasSwitch(Crystal.CATH_ORBS) | Filtered(can_crystal & Has(KeyItem.BELL), options=switch_off) ), (R.CATH_RIGHT_SHAFT_CONNECTION, R.CATH_RIGHT_SHAFT): Has(KeyItem.BELL) & has_zeek & has_bow, (R.CATH_RIGHT_SHAFT, R.CATH_TOP): has_claw, @@ -848,9 +848,7 @@ (R.SP_STAR, R.SP_SHAFT): Has(KeyItem.BELL) & has_algus_meteor & chalice_on_easy & HasSwitch(Crystal.SP_STAR), (R.SP_STAR, R.SP_STAR_CONNECTION): has_star, (R.SP_STAR_CONNECTION, R.SP_STAR): has_star, - (R.SP_STAR_CONNECTION, R.SP_STAR_END): ( - has_star & (HasSwitch(Switch.SP_AFTER_STAR) | And(has_arias, options=switch_off)) - ), + (R.SP_STAR_CONNECTION, R.SP_STAR_END): has_star & (HasSwitch(Switch.SP_AFTER_STAR) | (has_arias << switch_off)), (R.SP_STAR_END, R.SP_STAR_CONNECTION): has_star & HasSwitch(Switch.SP_AFTER_STAR), } @@ -870,7 +868,7 @@ L.HOTP_MAIDEN_RING: HasSwitch(Crystal.HOTP_MAIDEN_1, Crystal.HOTP_MAIDEN_2) | otherwise_crystal, L.TR_ADORNED_KEY: ( HasSwitch(Switch.TR_ADORNED_L, Switch.TR_ADORNED_M, Switch.TR_ADORNED_R) - | And( + | Filtered( has_claw & has_zeek & HasAll(Eye.RED, KeyItem.BELL) @@ -914,7 +912,7 @@ has_kyuli & ( HasSwitch(Crystal.CATA_POISON_ROOTS) - | And(can_crystal & Has(KeyItem.BELL), options=switch_off) + | Filtered(can_crystal & Has(KeyItem.BELL), options=switch_off) | HardLogic(Has(KeyItem.ICARUS) & has_claw) ) ), From 9df619da490df7179c6a3bdde48ed766784c72b5 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 25 Nov 2025 01:07:59 -0500 Subject: [PATCH 091/135] kill off TYPE_CHECKING by any means necessary --- worlds/astalon/bases.py | 25 ++++++++++++++++++++++++ worlds/astalon/client.py | 7 +++++-- worlds/astalon/constants.py | 1 - worlds/astalon/items.py | 22 ++++++++------------- worlds/astalon/logic/custom_rules.py | 28 +++++++++++++-------------- worlds/astalon/logic/main_campaign.py | 10 +++------- worlds/astalon/tracker.py | 24 +++++++---------------- worlds/astalon/world.py | 23 ++++++++-------------- 8 files changed, 69 insertions(+), 71 deletions(-) create mode 100644 worlds/astalon/bases.py diff --git a/worlds/astalon/bases.py b/worlds/astalon/bases.py new file mode 100644 index 000000000000..104c152c6139 --- /dev/null +++ b/worlds/astalon/bases.py @@ -0,0 +1,25 @@ +from abc import ABCMeta +from typing import Any + +from rule_builder import RuleWorldMixin +from worlds.AutoWorld import AutoWorldRegister, World + +from .constants import GAME_NAME +from .items import Character +from .options import AstalonOptions + + +class AstalonWorldMetaclass(AutoWorldRegister, ABCMeta): + def __new__(mcs, name: str, bases: tuple[type, ...], dct: dict[str, Any]) -> AutoWorldRegister: + if name == "AstalonWorld": + return super().__new__(mcs, name, bases, dct) + return super(AutoWorldRegister, mcs).__new__(mcs, name, bases, dct) + + +class AstalonWorldBase(RuleWorldMixin, World, metaclass=AstalonWorldMetaclass): # pyright: ignore[reportUnsafeMultipleInheritance] + game = GAME_NAME + options_dataclass = AstalonOptions + + options: AstalonOptions # pyright: ignore[reportIncompatibleVariableOverride] + starting_characters: list[Character] + extra_gold_eyes: int = 0 diff --git a/worlds/astalon/client.py b/worlds/astalon/client.py index 5659189c2bf3..fba5d69d27fd 100644 --- a/worlds/astalon/client.py +++ b/worlds/astalon/client.py @@ -14,9 +14,10 @@ from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] from worlds.AutoWorld import World -from .constants import GAME_NAME, VERSION +from .constants import GAME_NAME from .items import item_table from .locations import location_table +from .world import AstalonWorld try: from worlds.tracker.TrackerClient import UT_VERSION, TrackerCommandProcessor, TrackerGameContext @@ -256,7 +257,9 @@ def _handle_color(self, node: JSONMessagePart) -> str: class AstalonManager(ui): # core appends ap version so this works - base_title = f"Astalon Tracker v{VERSION} with UT {UT_VERSION} for AP version" + base_title = ( + f"Astalon Tracker v{AstalonWorld.world_version.as_simple_string()} with UT {UT_VERSION} for AP version" + ) ctx: "AstalonClientContext" def __init__(self, ctx: CommonContext) -> None: diff --git a/worlds/astalon/constants.py b/worlds/astalon/constants.py index a7b2354e6bd9..759b2a7a3037 100644 --- a/worlds/astalon/constants.py +++ b/worlds/astalon/constants.py @@ -2,4 +2,3 @@ GAME_NAME: Final[str] = "Astalon" BASE_ID: Final[int] = 333000 -VERSION: Final[str] = "0.25.1" diff --git a/worlds/astalon/items.py b/worlds/astalon/items.py index f69270d6bcfb..a42e0e1f6480 100644 --- a/worlds/astalon/items.py +++ b/worlds/astalon/items.py @@ -2,14 +2,12 @@ from dataclasses import dataclass from enum import Enum from itertools import groupby -from typing import TYPE_CHECKING, TypeAlias +from typing import TypeAlias from BaseClasses import Item, ItemClassification from .constants import BASE_ID, GAME_NAME - -if TYPE_CHECKING: - from . import AstalonWorld +from .options import AstalonOptions class ItemGroup(str, Enum): @@ -507,7 +505,7 @@ class AstalonItem(Item): @dataclass(frozen=True) class ItemData: name: ItemName - classification: ItemClassification | Callable[["AstalonWorld"], ItemClassification] + classification: ItemClassification | Callable[[AstalonOptions], ItemClassification] quantity_in_item_pool: int group: ItemGroup description: str = "" @@ -593,9 +591,7 @@ class ItemData: ItemData(BlueDoor.CAVES, ItemClassification.progression, 1, ItemGroup.DOOR_BLUE), ItemData( BlueDoor.CATA_ORBS, - lambda world: ( - ItemClassification.progression if world.options.randomize_candles else ItemClassification.useful - ), + lambda options: ItemClassification.progression if options.randomize_candles else ItemClassification.useful, 1, ItemGroup.DOOR_BLUE, ), @@ -606,9 +602,7 @@ class ItemData: ItemData(BlueDoor.CATA_PRISON_LEFT, ItemClassification.filler, 1, ItemGroup.DOOR_BLUE), ItemData( BlueDoor.CATA_PRISON_RIGHT, - lambda world: ( - ItemClassification.progression if world.options.randomize_candles else ItemClassification.filler - ), + lambda options: ItemClassification.progression if options.randomize_candles else ItemClassification.filler, 1, ItemGroup.DOOR_BLUE, ), @@ -667,13 +661,13 @@ class ItemData: ItemData(Switch.GT_UPPER_PATH_ACCESS, ItemClassification.progression, 1, ItemGroup.SWITCH), ItemData( Switch.GT_CROSSES, - lambda world: (ItemClassification.filler if world.options.open_early_doors else ItemClassification.progression), + lambda options: ItemClassification.filler if options.open_early_doors else ItemClassification.progression, 1, ItemGroup.SWITCH, ), ItemData( Switch.GT_GH_SHORTCUT, - lambda world: (ItemClassification.filler if world.options.open_early_doors else ItemClassification.progression), + lambda options: ItemClassification.filler if options.open_early_doors else ItemClassification.progression, 1, ItemGroup.SWITCH, ), @@ -879,7 +873,7 @@ def get_item_group(item_name: str): item_name_groups: dict[str, set[str]] = { group.value: set(item_names) for group, item_names in groupby(sorted(item_table, key=get_item_group), get_item_group) - if group != "" + if group } item_name_groups["Map Progression"] = { diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index c2149f906bf2..5ff4712c0faa 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -1,7 +1,7 @@ import dataclasses from collections.abc import Iterable from enum import Enum -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import Any, ClassVar, cast from typing_extensions import override @@ -10,6 +10,7 @@ from NetUtils import JSONMessagePart from Options import Option +from ..bases import AstalonWorldBase from ..constants import GAME_NAME from ..items import ( BlueDoor, @@ -36,9 +37,6 @@ ) from ..regions import RegionName -if TYPE_CHECKING: - from ..world import AstalonWorld - def as_str(value: Enum | str | None) -> str: if value is None: @@ -47,7 +45,7 @@ def as_str(value: Enum | str | None) -> str: @dataclasses.dataclass(init=False) -class Has(rule_builder.Has["AstalonWorld"], game=GAME_NAME): +class Has(rule_builder.Has[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -60,7 +58,7 @@ def __init__( @dataclasses.dataclass(init=False) -class HasAll(rule_builder.HasAll["AstalonWorld"], game=GAME_NAME): +class HasAll(rule_builder.HasAll[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -75,7 +73,7 @@ def __init__( @dataclasses.dataclass(init=False) -class HasAny(rule_builder.HasAny["AstalonWorld"], game=GAME_NAME): +class HasAny(rule_builder.HasAny[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -90,7 +88,7 @@ def __init__( @dataclasses.dataclass(init=False) -class CanReachLocation(rule_builder.CanReachLocation["AstalonWorld"], game=GAME_NAME): +class CanReachLocation(rule_builder.CanReachLocation[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -104,7 +102,7 @@ def __init__( @dataclasses.dataclass(init=False) -class CanReachRegion(rule_builder.CanReachRegion["AstalonWorld"], game=GAME_NAME): +class CanReachRegion(rule_builder.CanReachRegion[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -116,7 +114,7 @@ def __init__( @dataclasses.dataclass(init=False) -class CanReachEntrance(rule_builder.CanReachEntrance["AstalonWorld"], game=GAME_NAME): +class CanReachEntrance(rule_builder.CanReachEntrance[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -135,7 +133,7 @@ class ToggleRule(HasAll, game=GAME_NAME): otherwise: bool = False @override - def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: + def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: items = tuple(cast(ItemName | Events, item) for item in self.item_names) if len(items) == 1: rule = Has(items[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) @@ -218,9 +216,9 @@ def __init__(self, elevator: Elevator, *, options: Iterable[rule_builder.OptionF @dataclasses.dataclass() -class HasGoal(rule_builder.Rule["AstalonWorld"], game=GAME_NAME): +class HasGoal(rule_builder.Rule[AstalonWorldBase], game=GAME_NAME): @override - def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: + def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: if world.options.goal.value != Goal.option_eye_hunt: return world.true_rule return Has.Resolved( @@ -232,9 +230,9 @@ def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: @dataclasses.dataclass() -class HardLogic(rule_builder.WrapperRule["AstalonWorld"], game=GAME_NAME): +class HardLogic(rule_builder.WrapperRule[AstalonWorldBase], game=GAME_NAME): @override - def _instantiate(self, world: "AstalonWorld") -> rule_builder.Rule.Resolved: + def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: if world.options.difficulty.value == Difficulty.option_hard: return self.child.resolve(world) if getattr(world.multiworld, "generation_is_fake", False): diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 46e91fd1b0cf..53a4ac768c5a 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1,7 +1,6 @@ -from typing import TYPE_CHECKING - from rule_builder import Filtered, OptionFilter, Rule, True_ +from ..bases import AstalonWorldBase from ..items import ( BlueDoor, Character, @@ -41,9 +40,6 @@ HasWhite, ) -if TYPE_CHECKING: - from ..world import AstalonWorld - easy = [OptionFilter(Difficulty, Difficulty.option_easy)] characters_off = [OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)] characters_on = [OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")] @@ -118,7 +114,7 @@ shop_moderate = CanReachRegion(R.MECH_START) shop_expensive = CanReachRegion(R.ROA_START) -MAIN_ENTRANCE_RULES: dict[tuple[R, R], Rule["AstalonWorld"]] = { +MAIN_ENTRANCE_RULES: dict[tuple[R, R], Rule[AstalonWorldBase]] = { (R.SHOP, R.SHOP_ALGUS): has_algus, (R.SHOP, R.SHOP_ARIAS): has_arias, (R.SHOP, R.SHOP_KYULI): has_kyuli, @@ -852,7 +848,7 @@ (R.SP_STAR_END, R.SP_STAR_CONNECTION): has_star & HasSwitch(Switch.SP_AFTER_STAR), } -MAIN_LOCATION_RULES: dict[L, Rule["AstalonWorld"]] = { +MAIN_LOCATION_RULES: dict[L, Rule[AstalonWorldBase]] = { L.GT_GORGONHEART: ( HasSwitch(Switch.GT_GH, otherwise=True) | has_kyuli | has_boots | has_block | has_cloak | Has(KeyItem.ICARUS) ), diff --git a/worlds/astalon/tracker.py b/worlds/astalon/tracker.py index 6d0c52e5fead..3adefd83a753 100644 --- a/worlds/astalon/tracker.py +++ b/worlds/astalon/tracker.py @@ -1,7 +1,5 @@ -# pyright: reportUninitializedInstanceVariable=false - from functools import cached_property -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar from typing_extensions import override @@ -12,13 +10,9 @@ from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] from worlds.generic.Rules import CollectionRule +from .bases import AstalonWorldBase from .items import Character, Events -if TYPE_CHECKING: - from worlds.AutoWorld import World -else: - World = object - def map_page_index(data: Any) -> int: """Converts the area id provided by the game mod to a map index.""" @@ -27,16 +21,16 @@ def map_page_index(data: Any) -> int: if data in (1, 99): # tomb return 1 - elif data in (2, 3, 7): + if data in (2, 3, 7): # mechanism_and_hall return 2 - elif data in (4, 19, 21): + if data in (4, 19, 21): # catacombs return 3 - elif data in (5, 6, 8, 13): + if data in (5, 6, 8, 13): # ruins return 4 - elif data == 11: + if data == 11: # cyclops return 5 # world map @@ -89,7 +83,7 @@ def rule_to_json(rule: CollectionRule | None, state: CollectionState) -> list[JS ] -class UTMxin(World): +class AstalonUTWorld(AstalonWorldBase): tracker_world: ClassVar[dict[str, Any]] = { "map_page_folder": "tracker", "map_page_maps": "maps/maps.json", @@ -102,10 +96,6 @@ class UTMxin(World): ut_can_gen_without_yaml: ClassVar[bool] = True glitches_item_name: ClassVar[str] = Events.FAKE_OOL_ITEM.value - if TYPE_CHECKING: - starting_characters: list[Character] - extra_gold_eyes: int - @cached_property def is_ut(self) -> bool: return getattr(self.multiworld, "generation_is_fake", False) diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 30b4b0bbaa79..dab2fffd1d6c 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -6,10 +6,8 @@ from BaseClasses import Item, ItemClassification, MultiWorld, Region from Options import OptionError -from rule_builder import RuleWorldMixin -from worlds.AutoWorld import World -from .constants import GAME_NAME, VERSION +from .constants import GAME_NAME from .items import ( CHARACTERS, EARLY_BLUE_DOORS, @@ -40,9 +38,9 @@ location_table, ) from .logic.main_campaign import COMPLETION_RULE, MAIN_ENTRANCE_RULES, MAIN_LOCATION_RULES -from .options import ApexElevator, AstalonOptions, Goal, RandomizeCharacters +from .options import ApexElevator, Goal, RandomizeCharacters from .regions import RegionName, astalon_regions -from .tracker import UTMxin +from .tracker import AstalonUTWorld from .web_world import AstalonWebWorld # ██░░░██████░░███░░░███ @@ -80,7 +78,7 @@ } -class AstalonWorld(UTMxin, RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] +class AstalonWorld(AstalonUTWorld): """ Uphold your pact with the Titan of Death, Epimetheus! Fight, climb and solve your way through a twisted tower as three unique adventurers, @@ -89,17 +87,12 @@ class AstalonWorld(UTMxin, RuleWorldMixin, World): # pyright: ignore[reportUnsa game = GAME_NAME web = AstalonWebWorld() - options_dataclass = AstalonOptions - options: AstalonOptions # pyright: ignore[reportIncompatibleVariableOverride] item_name_groups = item_name_groups location_name_groups = location_name_groups item_name_to_id = item_name_to_id location_name_to_id = location_name_to_id rule_caching_enabled = True - starting_characters: list[Character] - extra_gold_eyes: int = 0 - _character_strengths: ClassVar[dict[int, dict[str, float]] | None] = None def __init__(self, multiworld: MultiWorld, player: int) -> None: @@ -239,7 +232,7 @@ def create_item(self, name: str) -> AstalonItem: item_data = item_table[name] classification: ItemClassification if callable(item_data.classification): - classification = item_data.classification(self) + classification = item_data.classification(self.options) else: classification = item_data.classification return AstalonItem(name, classification, self.item_name_to_id[name], self.player) @@ -351,8 +344,8 @@ def create_items(self) -> None: remove_count = len(itempool) + len(filler_items) - total_locations if remove_count > len(filler_items): raise OptionError( - f"Astalon player {self.player_name} failed: No space for eye hunt. " - + "Lower your eye hunt goal or enable candle randomizer." + f"Astalon player {self.player_name} failed: No space for eye hunt. " # pyright: ignore[reportImplicitStringConcatenation] + "Lower your eye hunt goal or enable candle randomizer." ) if remove_count == len(filler_items): @@ -429,7 +422,7 @@ def fill_slot_data(self) -> dict[str, Any]: assert self._character_strengths is not None strengths = self._character_strengths.get(self.player, {}) return { - "version": VERSION, + "version": self.world_version.as_simple_string(), "options": self.options.as_dict( "difficulty", "goal", From 07aaefa71ef7cab938c872ac7a99a79693f06a40 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 25 Nov 2025 01:56:02 -0500 Subject: [PATCH 092/135] get ready for v1 --- worlds/astalon/__init__.py | 19 -- worlds/astalon/archipelago.json | 4 +- worlds/astalon/client.py | 321 -------------------------- worlds/astalon/constants.py | 1 - worlds/astalon/images/pil.png | Bin 461 -> 0 bytes worlds/astalon/items.py | 52 ++--- worlds/astalon/locations.py | 16 +- worlds/astalon/logic/main_campaign.py | 2 +- worlds/astalon/options.py | 1 + worlds/astalon/regions.py | 4 +- worlds/astalon/requirements-dev.txt | 6 +- worlds/astalon/tracker.py | 18 +- 12 files changed, 51 insertions(+), 393 deletions(-) delete mode 100644 worlds/astalon/client.py delete mode 100644 worlds/astalon/images/pil.png diff --git a/worlds/astalon/__init__.py b/worlds/astalon/__init__.py index eaa45a9fb340..73bd75c5f125 100644 --- a/worlds/astalon/__init__.py +++ b/worlds/astalon/__init__.py @@ -1,22 +1,3 @@ -from worlds.LauncherComponents import ( - Component, - Type, - components, - icon_paths, - launch_subprocess, # pyright: ignore[reportUnknownVariableType] -) - from .world import AstalonWorld - -def launch_client() -> None: - from .client import launch - - launch_subprocess(launch, name="Astalon Tracker") - - -components.append(Component("Astalon Tracker", func=launch_client, component_type=Type.CLIENT, icon="astalon")) - -icon_paths["astalon"] = f"ap:{__name__}/images/pil.png" - __all__ = ("AstalonWorld",) diff --git a/worlds/astalon/archipelago.json b/worlds/astalon/archipelago.json index 0c2c7ecc1506..e41d8b8d450b 100644 --- a/worlds/astalon/archipelago.json +++ b/worlds/astalon/archipelago.json @@ -1,6 +1,6 @@ { "game": "Astalon", "authors": ["DrTChops"], - "world_version": "0.26.0", - "minimum_ap_version": "0.6.0" + "world_version": "1.0.0", + "minimum_ap_version": "0.6.4" } diff --git a/worlds/astalon/client.py b/worlds/astalon/client.py deleted file mode 100644 index fba5d69d27fd..000000000000 --- a/worlds/astalon/client.py +++ /dev/null @@ -1,321 +0,0 @@ -import argparse -import asyncio -import urllib.parse -from collections.abc import Callable -from typing import NamedTuple, cast - -from typing_extensions import override - -from BaseClasses import CollectionState, Entrance, Location, Region -from CommonClient import CommonContext, get_base_parser, gui_enabled, logger, server_loop -from MultiServer import mark_raw -from NetUtils import JSONMessagePart -from rule_builder import Rule -from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] -from worlds.AutoWorld import World - -from .constants import GAME_NAME -from .items import item_table -from .locations import location_table -from .world import AstalonWorld - -try: - from worlds.tracker.TrackerClient import UT_VERSION, TrackerCommandProcessor, TrackerGameContext - - tracker_loaded = True -except ImportError: - from CommonClient import ClientCommandProcessor - - class TrackerCore: - player_id: int - - def get_current_world(self) -> World | None: ... - - class CurrentTrackerState(NamedTuple): - state: CollectionState - - class TrackerGameContext(CommonContext): - tracker_core: TrackerCore - - def run_generator(self) -> None: ... - def updateTracker(self) -> CurrentTrackerState: ... - - class TrackerCommandProcessor(ClientCommandProcessor): - ctx: TrackerGameContext - - tracker_loaded = False - UT_VERSION = "Not found" - - -class AstalonCommandProcessor(TrackerCommandProcessor): - ctx: "AstalonClientContext" - - def _print_rule(self, rule: Callable[[CollectionState], bool], state: CollectionState) -> None: - if isinstance(rule, Rule.Resolved): - if self.ctx.ui: - messages: list[JSONMessagePart] = [{"type": "text", "text": " "}] - messages.extend(rule.explain_json(state)) - self.ctx.ui.print_json(messages) - else: - logger.info(" " + rule.explain_str(state)) - else: - if self.ctx.ui: - self.ctx.ui.print_json( - [ - {"type": "text", "text": " "}, - {"type": "color", "color": "green", "text": "True"}, - ] - ) - else: - logger.info(" True") - - if tracker_loaded: - - @mark_raw - def _cmd_route(self, location_or_region: str = "") -> None: - """Explain the route to get to a location or region""" - world = self.ctx.get_world() - if not world: - logger.info("Not yet loaded into a game") - return - - if self.ctx.stored_data and self.ctx.stored_data.get("_read_race_mode"): - logger.info("Route is disabled during Race Mode") - return - - if not location_or_region: - logger.info("Provide a location or region to route to using /route [name]") - return - - goal_location: Location | None = None - goal_region: Region | None = None - region_name = "" - location_name, usable, response = get_intended_text( - location_or_region, - [loc.name for loc in world.get_locations()], - ) - if usable: - try: - goal_location = world.get_location(location_name) - except KeyError: - logger.warning(f"Location {location_name} not found in this multiworld") - return - goal_region = goal_location.parent_region - if not goal_region: - logger.warning(f"Location {location_name} has no parent region") - return - else: - region_name, usable, _ = get_intended_text( - location_or_region, - [reg.name for reg in world.get_regions()], - ) - if usable: - goal_region = world.get_region(region_name) - else: - logger.warning(response) - return - - state = self.ctx.get_updated_state() - if goal_location and not goal_location.can_reach(state): - logger.warning(f"Location {goal_location.name} cannot be reached") - return - if goal_region and goal_region not in state.path: - logger.warning(f"Region {goal_region.name} cannot be reached") - return - - path: list[Entrance] = [] - name, connection = state.path[goal_region] - while connection != ("Menu", None) and connection is not None: - name, connection = connection - if "->" in name and "Menu" not in name: - path.append(world.get_entrance(name)) - - path.reverse() - for p in path: - if self.ctx.ui: - self.ctx.ui.print_json([{"type": "entrance_name", "text": p.name, "player": self.ctx.player}]) - else: - logger.info(p.name) - self._print_rule(p.access_rule, state) - - if goal_location: - if self.ctx.ui: - self.ctx.ui.print_json( - [ - {"type": "text", "text": "-> "}, - { - "type": "location_name", - "text": goal_location.name, - "player": self.ctx.player, - }, - ] - ) - else: - logger.info(f"-> {goal_location.name}") - self._print_rule(goal_location.access_rule, state) - - -class AstalonClientContext(TrackerGameContext): - game = GAME_NAME - command_processor = AstalonCommandProcessor - - # These 3 are for UT refactor compat - @property - def player(self) -> int: - try: - return self.player_id - except AttributeError: - return self.tracker_core.player_id - - def get_world(self) -> World | None: - try: - return self.tracker_core.get_current_world() - except AttributeError: - return self.multiworld.worlds[self.player] - - def get_updated_state(self) -> CollectionState: - try: - from worlds.tracker.TrackerClient import updateTracker - - return updateTracker(self).state - except: - return self.updateTracker().state - - def make_gui(self): - ui = super().make_gui() # before the kivy imports so kvui gets loaded first - - from kivy.utils import escape_markup - - from kvui import KivyJSONtoTextParser - - try: - from worlds.tracker.TrackerClient import get_ut_color - except ImportError: - get_ut_color = None - - class AstalonJSONtoTextParser(KivyJSONtoTextParser): - ctx: CommonContext - - @override - def _handle_item_name(self, node: JSONMessagePart) -> str: - flags = node.get("flags", 0) - item_types: list[str] = [] - if flags & 0b001: # advancement - item_types.append("progression") - if flags & 0b010: # useful - item_types.append("useful") - if flags & 0b100: # trap - item_types.append("trap") - if not item_types: - item_types.append("normal") - tooltip = "Item Class: " + ", ".join(item_types) - - player = node.get("player", 0) - slot_info = self.ctx.slot_info.get(player) - item_name = node.get("text", "") - metadata = item_table.get(item_name) - if slot_info and slot_info.game == GAME_NAME and metadata and metadata.description: - tooltip += f"
{metadata.description}" - - node.setdefault("refs", []).append(tooltip) - if node.get("color"): - return self._handle_color(node) - return super(KivyJSONtoTextParser, self)._handle_item_name(node) - - @override - def _handle_location_name(self, node: JSONMessagePart) -> str: - player = node.get("player", 0) - slot_info = self.ctx.slot_info.get(player) - location_name = node.get("text", "") - metadata = location_table.get(location_name) - if slot_info and slot_info.game == GAME_NAME and metadata: - parts: list[str] = [] - if metadata.room: - parts.append(f"{metadata.area.value} ({metadata.room})") - else: - parts.append(metadata.area.value) - parts.append(f"Region: {metadata.region.value}") - if metadata.description: - parts.append(metadata.description) - node.setdefault("refs", []).append("
".join(parts)) - return super()._handle_location_name(node) - - @override - def _handle_color(self, node: JSONMessagePart) -> str: - colors = node.get("color", "").split(";") - node["text"] = escape_markup(node.get("text", "")) - for color in colors: - color_code = None - if get_ut_color: - color_code = get_ut_color(color) - if not color_code or color_code == "DD00FF": - color_code = self.color_codes.get(color, None) - if color_code: - node["text"] = f"[color={color_code}]{node['text']}[/color]" - return self._handle_text(node) - return self._handle_text(node) - - class AstalonManager(ui): - # core appends ap version so this works - base_title = ( - f"Astalon Tracker v{AstalonWorld.world_version.as_simple_string()} with UT {UT_VERSION} for AP version" - ) - ctx: "AstalonClientContext" - - def __init__(self, ctx: CommonContext) -> None: - super().__init__(ctx) - self.json_to_kivy_parser = AstalonJSONtoTextParser(ctx) - - @override - def build(self): - container = super().build() - if not tracker_loaded: - logger.info("To enable the tracker and map pages, install Universal Tracker.") - - return container - - return AstalonManager - - -class ClientArgs(argparse.Namespace): - connect: str | None = None - password: str | None = None - nogui: bool = False - name: str | None = None - url: str | None = None - - -async def main(args: ClientArgs) -> None: - ctx = AstalonClientContext(args.connect, args.password) - - ctx.auth = args.name - ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") - - if tracker_loaded: - ctx.run_generator() - else: - logger.warning("Could not find Universal Tracker.") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - await ctx.exit_event.wait() - await ctx.shutdown() - - -def launch(*args: str) -> None: - parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") - parser.add_argument("--name", default=None, help="Slot Name to connect as.") - parser.add_argument("url", nargs="?", help="Archipelago connection url") - parsed_args = cast(ClientArgs, parser.parse_args(args)) - - if parsed_args.url: - url = urllib.parse.urlparse(parsed_args.url) - parsed_args.connect = url.netloc - if url.username: - parsed_args.name = urllib.parse.unquote(url.username) - if url.password: - parsed_args.password = urllib.parse.unquote(url.password) - - asyncio.run(main(parsed_args)) diff --git a/worlds/astalon/constants.py b/worlds/astalon/constants.py index 759b2a7a3037..36dbb976f2be 100644 --- a/worlds/astalon/constants.py +++ b/worlds/astalon/constants.py @@ -1,4 +1,3 @@ from typing import Final GAME_NAME: Final[str] = "Astalon" -BASE_ID: Final[int] = 333000 diff --git a/worlds/astalon/images/pil.png b/worlds/astalon/images/pil.png deleted file mode 100644 index eadefc6880a800af6c5706df59d0d5698f35c100..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 461 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJV{wqX6T`Z5GB1G~mUKs7M+SzC z{oH>NS%G|>0G|+74hDuFe}8_f3IPi2Z$0t^q^=~$FZloe{|pXVE588woCO|{#S9F5 zhe4R}c>anMprAyFYeY$Kep*R+Vo@qXKw@TIiJqTph=Qq}p`MA*{bkKSHQQ1nJkvZq zwHSbwFt9R6F|sl+0$E-_EDdFYT&=;#3>IesvJDxTz`lqAvNPLRz~WgzHV7mDF-Sj* zMzfTGnSo&fI|B<)g@KW=0pkLQsURJ!3m_&<0kT1W31|)zSY?o<1&{^RWoTdklD+w04;kYeb{~rVD$#%L&W5#&sp$ ItemGroup: return item_table[item_name].group diff --git a/worlds/astalon/locations.py b/worlds/astalon/locations.py index 88dcc8a911f5..1e6780cedc0f 100644 --- a/worlds/astalon/locations.py +++ b/worlds/astalon/locations.py @@ -1,14 +1,14 @@ from dataclasses import dataclass -from enum import Enum +from enum import StrEnum from itertools import groupby from BaseClasses import Location -from .constants import BASE_ID, GAME_NAME +from .constants import GAME_NAME from .regions import RegionName -class Area(str, Enum): +class Area(StrEnum): SHOP = "Shop" GT = "Gorgon Tomb" MECH = "Mechanism" @@ -24,7 +24,7 @@ class Area(str, Enum): SP = "Serpent Path" -class LocationGroup(str, Enum): +class LocationGroup(StrEnum): CHARACTER = "Characters" ITEM = "Items" FAMILIAR = "Familiars" @@ -39,7 +39,7 @@ class LocationGroup(str, Enum): CANDLE = "Candles" -class LocationName(str, Enum): +class LocationName(StrEnum): GT_ALGUS = "Gorgon Tomb - Algus" GT_ARIAS = "Gorgon Tomb - Arias" GT_KYULI = "Gorgon Tomb - Kyuli" @@ -510,7 +510,7 @@ class LocationName(str, Enum): class AstalonLocation(Location): - game: str = GAME_NAME + game = GAME_NAME @dataclass(frozen=True) @@ -1103,8 +1103,8 @@ class LocationData: LocationData(LocationName.CATH_CANDLE_TOP_2, RegionName.CATH_TOP, LocationGroup.CANDLE, Area.CATH), ) -location_table = {location.name.value: location for location in ALL_LOCATIONS} -location_name_to_id: dict[str, int] = {data.name.value: i for i, data in enumerate(ALL_LOCATIONS, start=BASE_ID)} +location_table: dict[str, LocationData] = {location.name.value: location for location in ALL_LOCATIONS} +location_name_to_id: dict[str, int] = {data.name.value: i for i, data in enumerate(ALL_LOCATIONS, start=1)} def get_location_group(location_name: str) -> LocationGroup: diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 53a4ac768c5a..bf10cc0bbea7 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1092,4 +1092,4 @@ L.CATA_CANDLE_PRISON: HasBlue(BlueDoor.CATA_PRISON_RIGHT, otherwise=True), } -COMPLETION_RULE = Has(Events.VICTORY) +COMPLETION_RULE: Rule[AstalonWorldBase] = Has(Events.VICTORY) diff --git a/worlds/astalon/options.py b/worlds/astalon/options.py index 89f5e2e8ddfd..c3eb91226c24 100644 --- a/worlds/astalon/options.py +++ b/worlds/astalon/options.py @@ -202,6 +202,7 @@ class RandomizeCandles(Toggle): class RandomizeOrbRocks(Toggle): """ + NOT YET SUPPORTED Choose whether to randomize the reward gained from breaking orb rocks. """ diff --git a/worlds/astalon/regions.py b/worlds/astalon/regions.py index 687d64d04032..afa9675a7372 100644 --- a/worlds/astalon/regions.py +++ b/worlds/astalon/regions.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from enum import Enum +from enum import StrEnum -class RegionName(str, Enum): +class RegionName(StrEnum): MENU = "Menu" SHOP = "Shop" SHOP_ALGUS = "Shop - Algus" diff --git a/worlds/astalon/requirements-dev.txt b/worlds/astalon/requirements-dev.txt index c37120d8748c..794c71ae499e 100644 --- a/worlds/astalon/requirements-dev.txt +++ b/worlds/astalon/requirements-dev.txt @@ -1,3 +1,3 @@ -pyright==1.1.405 -pytest==8.4.2 -ruff==0.13.2 +pyright==1.1.407 +pytest==9.0.1 +ruff==0.14.6 diff --git a/worlds/astalon/tracker.py b/worlds/astalon/tracker.py index 5293d1157fec..07c9bc5254bb 100644 --- a/worlds/astalon/tracker.py +++ b/worlds/astalon/tracker.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import Any, ClassVar +from typing import Any, ClassVar, Final from typing_extensions import override @@ -8,11 +8,11 @@ from Options import Option from rule_builder import Rule from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] -from worlds.astalon.logic.custom_rules import CampfireWarp from worlds.generic.Rules import CollectionRule from .bases import AstalonWorldBase from .items import Character, Events +from .logic.custom_rules import CampfireWarp from .regions import RegionName @@ -39,7 +39,7 @@ def map_page_index(data: Any) -> int: return 0 -CHARACTER_ICONS = { +CHARACTER_ICONS: Final[dict[int, str]] = { 1: "algus", 2: "arias", 3: "kyuli", @@ -47,7 +47,7 @@ def map_page_index(data: Any) -> int: 5: "zeek", } -MAP_OFFSETS = ( +MAP_OFFSETS: Final[tuple[tuple[int, int], ...]] = ( (-1800, 17180), # world map (-4152, 25130), # gt (-1560, 21080), # mech and hotp @@ -55,12 +55,12 @@ def map_page_index(data: Any) -> int: (-2424, 17000), # ruins (-9336, 20840), # cyclops ) -ROOM_WIDTH = 432 -ROOM_HEIGHT = 240 -MAP_SCALE_X = ROOM_WIDTH / 59.346 -MAP_SCALE_Y = -ROOM_HEIGHT / 40.475 +ROOM_WIDTH: Final[int] = 432 +ROOM_HEIGHT: Final[int] = 240 +MAP_SCALE_X: Final[float] = ROOM_WIDTH / 59.346 +MAP_SCALE_Y: Final[float] = -ROOM_HEIGHT / 40.475 -CAMPFIRE_WARPS = { +CAMPFIRE_WARPS: Final[dict[int, RegionName]] = { 6696: RegionName.GT_ENTRANCE, 18: RegionName.GT_BOTTOM, 292: RegionName.GT_LEFT, From 42af4ee0101f50133b41fd41837e89306edf718f Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 25 Nov 2025 23:36:40 -0500 Subject: [PATCH 093/135] add missing height check --- worlds/astalon/logic/main_campaign.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index bf10cc0bbea7..7d44ed34181c 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -783,6 +783,7 @@ (R.TR_START, R.TR_LEFT): ( HasBlue(BlueDoor.TR, otherwise=True) & (HasRed(RedDoor.TR) | Filtered(has_claw & CanReachRegion(R.CATA_BOSS), options=red_off)) + & can_extra_height ), (R.TR_START, R.APEX): elevator_apex, (R.TR_START, R.GT_BOSS): HasElevator(Elevator.GT_2), From 014f1aa3a35bd5f357aaad3c5d4e5274c7be61cd Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 18:24:09 -0500 Subject: [PATCH 094/135] move simplify and instance caching code --- rule_builder.py | 264 +++++++++++++----------------- test/general/test_rule_builder.py | 16 ++ 2 files changed, 127 insertions(+), 153 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index aaa517e8f231..97319dc63f5f 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -20,9 +20,6 @@ class RuleWorldMixin(World): """A World mixin that provides helpers for interacting with the rule builder""" - rules_by_hash: dict[int, "Rule.Resolved"] - """A mapping of hash values to resolved rules""" - rule_item_dependencies: dict[str, set[int]] """A mapping of item name to set of rule ids""" @@ -54,7 +51,6 @@ class RuleWorldMixin(World): def __init__(self, multiworld: MultiWorld, player: int) -> None: super().__init__(multiworld, player) - self.rules_by_hash = {} self.rule_item_dependencies = defaultdict(set) self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) @@ -76,17 +72,7 @@ def rule_from_dict(cls, data: Mapping[str, Any]) -> "Rule[Self]": def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": """Returns a resolved rule registered with the caching system for this world""" - resolved_rule = rule.resolve(self) - resolved_rule = self.simplify_rule(resolved_rule) - return self.get_cached_rule(resolved_rule) - - def get_cached_rule(self, resolved_rule: "Rule.Resolved") -> "Rule.Resolved": - """Returns a cached instance of a resolved rule based on the hash""" - rule_hash = hash(resolved_rule) - if rule_hash in self.rules_by_hash: - return self.rules_by_hash[rule_hash] - self.rules_by_hash[rule_hash] = resolved_rule - return resolved_rule + return rule.resolve(self) def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: """Registers a rule's item, region, location, and entrance dependencies to this world instance""" @@ -172,143 +158,6 @@ def set_completion_rule(self, rule: "Rule[Self]") -> None: self.multiworld.completion_condition[self.player] = resolved_rule self.completion_rule = resolved_rule - def simplify_rule(self, rule: "Rule.Resolved") -> "Rule.Resolved": - """Simplify and optimize a resolved rule""" - if isinstance(rule, And.Resolved): - return self._simplify_and(rule) - if isinstance(rule, Or.Resolved): - return self._simplify_or(rule) - return rule - - def _simplify_and(self, rule: "And.Resolved") -> "Rule.Resolved": - children_to_process = list(rule.children) - clauses: list[Rule.Resolved] = [] - items: dict[str, int] = {} - true_rule: Rule.Resolved | None = None - - while children_to_process: - child = self.simplify_rule(children_to_process.pop(0)) - if child.always_false: - # false always wins - return child - if child.always_true: - # dedupe trues - true_rule = child - continue - if isinstance(child, And.Resolved): - children_to_process.extend(child.children) - continue - - if isinstance(child, Has.Resolved): - if child.item_name not in items or items[child.item_name] < child.count: - items[child.item_name] = child.count - elif isinstance(child, HasAll.Resolved): - for item in child.item_names: - if item not in items: - items[item] = 1 - else: - clauses.append(child) - - if not clauses and not items: - return true_rule or self.false_rule - - has_cls = cast(type[Has[Self]], self.get_rule_cls("Has")) - has_all_cls = cast(type[HasAll[Self]], self.get_rule_cls("HasAll")) - has_all_items: list[str] = [] - for item, count in items.items(): - if count == 1: - has_all_items.append(item) - else: - clauses.append( - self.get_cached_rule( - has_cls.Resolved(item, count, player=rule.player, caching_enabled=self.rule_caching_enabled) - ) - ) - - if len(has_all_items) == 1: - clauses.append( - self.get_cached_rule( - has_cls.Resolved(has_all_items[0], player=rule.player, caching_enabled=self.rule_caching_enabled) - ) - ) - elif len(has_all_items) > 1: - clauses.append( - self.get_cached_rule( - has_all_cls.Resolved( - tuple(has_all_items), - player=rule.player, - caching_enabled=self.rule_caching_enabled, - ) - ) - ) - - if len(clauses) == 1: - return clauses[0] - return And.Resolved(tuple(clauses), player=rule.player, caching_enabled=self.rule_caching_enabled) - - def _simplify_or(self, rule: "Or.Resolved") -> "Rule.Resolved": - children_to_process = list(rule.children) - clauses: list[Rule.Resolved] = [] - items: dict[str, int] = {} - - while children_to_process: - child = self.simplify_rule(children_to_process.pop(0)) - if child.always_true: - # true always wins - return child - if child.always_false: - # falses can be ignored - continue - if isinstance(child, Or.Resolved): - children_to_process.extend(child.children) - continue - - if isinstance(child, Has.Resolved): - if child.item_name not in items or child.count < items[child.item_name]: - items[child.item_name] = child.count - elif isinstance(child, HasAny.Resolved): - for item in child.item_names: - items[item] = 1 - else: - clauses.append(child) - - if not clauses and not items: - return self.false_rule - - has_cls = cast(type[Has[Self]], self.get_rule_cls("Has")) - has_any_cls = cast(type[HasAny[Self]], self.get_rule_cls("HasAny")) - has_any_items: list[str] = [] - for item, count in items.items(): - if count == 1: - has_any_items.append(item) - else: - clauses.append( - self.get_cached_rule( - has_cls.Resolved(item, count, player=rule.player, caching_enabled=self.rule_caching_enabled) - ) - ) - - if len(has_any_items) == 1: - clauses.append( - self.get_cached_rule( - has_cls.Resolved(has_any_items[0], player=rule.player, caching_enabled=self.rule_caching_enabled) - ) - ) - elif len(has_any_items) > 1: - clauses.append( - self.get_cached_rule( - has_any_cls.Resolved( - tuple(has_any_items), - player=rule.player, - caching_enabled=self.rule_caching_enabled, - ) - ) - ) - - if len(clauses) == 1: - return clauses[0] - return Or.Resolved(tuple(clauses), player=rule.player, caching_enabled=self.rule_caching_enabled) - @override def collect(self, state: CollectionState, item: Item) -> bool: changed = super().collect(state, item) @@ -466,8 +315,11 @@ def hash_impl(self: "Rule.Resolved") -> int: class CustomRuleRegister(type): """A metaclass to contain world custom rules and automatically convert resolved rules to frozen dataclasses""" + resolved_rules: ClassVar[dict[int, "Rule.Resolved"]] = {} + """A cached of resolved rules to turn each unique one into a singleton""" + custom_rules: ClassVar[dict[str, dict[str, type["Rule[Any]"]]]] = {} - """A mapping of game name to mapping of rule name to rule class""" + """A mapping of game name to mapping of rule name to rule class to hold custom rules implemented by worlds""" rule_name: str = "Rule" """The string name of a rule, must be unique per game""" @@ -488,6 +340,15 @@ def __new__( new_cls.rule_name = rule_name return dataclasses.dataclass(frozen=True)(new_cls) + @override + def __call__(cls, *args: Any, **kwds: Any) -> Any: + rule = super().__call__(*args, **kwds) + rule_hash = hash(rule) + if rule_hash in cls.resolved_rules: + return cls.resolved_rules[rule_hash] + cls.resolved_rules[rule_hash] = rule + return rule + @classmethod def get_rule_cls(cls, game_name: str, rule_name: str) -> type["Rule[Any]"]: """Returns the world-registered or default rule with the given name""" @@ -804,6 +665,56 @@ def entrance_dependencies(self) -> dict[str, set[int]]: class And(NestedRule[TWorld], game="Archipelago"): """A rule that only returns true when all child rules evaluate as true""" + @override + def _instantiate(self, world: TWorld) -> Rule.Resolved: + children_to_process = [world.resolve_rule(c) for c in self.children] + clauses: list[Rule.Resolved] = [] + items: dict[str, int] = {} + true_rule: Rule.Resolved | None = None + + while children_to_process: + child = children_to_process.pop(0) + if child.always_false: + # false always wins + return child + if child.always_true: + # dedupe trues + true_rule = child + continue + if isinstance(child, And.Resolved): + children_to_process.extend(child.children) + continue + + if isinstance(child, Has.Resolved): + if child.item_name not in items or items[child.item_name] < child.count: + items[child.item_name] = child.count + elif isinstance(child, HasAll.Resolved): + for item in child.item_names: + if item not in items: + items[item] = 1 + elif isinstance(child, HasAllCounts.Resolved): + for item, count in child.item_counts: + if item not in items or items[item] < count: + items[item] = count + else: + clauses.append(child) + + if not clauses and not items: + return true_rule or world.false_rule + + if len(items) == 1: + item, count = next(iter(items.items())) + clauses.append(Has(item, count).resolve(world)) + elif items and all(count == 1 for count in items.values()): + clauses.append(HasAll(*items).resolve(world)) + elif items: + clauses.append(HasAllCounts(items).resolve(world)) + + if len(clauses) == 1: + return clauses[0] + + return And.Resolved(tuple(clauses), player=world.player, caching_enabled=world.rule_caching_enabled) + class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: @@ -837,6 +748,53 @@ def __str__(self) -> str: class Or(NestedRule[TWorld], game="Archipelago"): """A rule that returns true when any child rule evaluates as true""" + @override + def _instantiate(self, world: TWorld) -> Rule.Resolved: + children_to_process = [world.resolve_rule(c) for c in self.children] + clauses: list[Rule.Resolved] = [] + items: dict[str, int] = {} + + while children_to_process: + child = children_to_process.pop(0) + if child.always_true: + # true always wins + return child + if child.always_false: + # falses can be ignored + continue + if isinstance(child, Or.Resolved): + children_to_process.extend(child.children) + continue + + if isinstance(child, Has.Resolved): + if child.item_name not in items or child.count < items[child.item_name]: + items[child.item_name] = child.count + elif isinstance(child, HasAny.Resolved): + for item in child.item_names: + items[item] = 1 + elif isinstance(child, HasAnyCount.Resolved): + for item, count in child.item_counts: + if item not in items or items[item] < count: + items[item] = count + else: + clauses.append(child) + + if not clauses and not items: + return world.false_rule + + if len(items) == 1: + item, count = next(iter(items.items())) + clauses.append(Has(item, count).resolve(world)) + elif items and all(count == 1 for count in items.values()): + clauses.append(HasAny(*items).resolve(world)) + elif items: + clauses.append(HasAnyCount(items).resolve(world)) + + if len(clauses) == 1: + return clauses[0] + + return Or.Resolved(tuple(clauses), player=world.player, caching_enabled=world.rule_caching_enabled) + class Resolved(NestedRule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index ddacbbe7e1f0..40a492f3f566 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -165,6 +165,22 @@ def get_filler_item_name(self) -> str: Or(Has("A"), False_()), Has.Resolved("A", player=1), ), + ( + And(Has("A"), HasAll("B", "C"), HasAllCounts({"D": 2, "E": 3})), + HasAllCounts.Resolved((("A", 1), ("B", 1), ("C", 1), ("D", 2), ("E", 3)), player=1), + ), + ( + And(Has("A"), HasAll("B", "C"), HasAllCounts({"D": 1, "E": 1})), + HasAll.Resolved(("A", "B", "C", "D", "E"), player=1), + ), + ( + Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 2, "E": 3})), + HasAnyCount.Resolved((("A", 1), ("B", 1), ("C", 1), ("D", 2), ("E", 3)), player=1), + ), + ( + Or(Has("A"), HasAny("B", "C"), HasAnyCount({"D": 1, "E": 1})), + HasAny.Resolved(("A", "B", "C", "D", "E"), player=1), + ), ) ) class TestSimplify(unittest.TestCase): From aa26195e906ec977b8dad5a7825b0db647187188 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 19:07:27 -0500 Subject: [PATCH 095/135] update docs --- docs/rule builder.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index cda915216061..522f92cae5c9 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -2,10 +2,19 @@ This document describes the API provided for the rule builder. Using this API prvoides you with with a simple interface to define rules and the following advantages: -- Automatic result caching +- Rule classes that avoid all the common pitfalls - Logic optimization +- Automatic result caching (opt-in) - Serialize/deserialize to JSON -- Human-readable logic explanations +- 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.Rule`. These are what you write for your laogic. They can be combined and take into account your world's options. There are a number of default rules listed blow, 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.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. +1. The rule builder world mixin class `RuleWorldMixin`, which is a class your world should inherit. It adds a number of helper functions related to assigning and resolving rules. ## Usage @@ -102,15 +111,25 @@ rule = ( ) ``` -If you would like to provide option filters when composing rules, you can use the `And` and `Or` rules directly: +If you would like to provide option filters when reusing or composing rules, you can use the `Filtered` helper rule: ```python -rule = Or( - And(Has("A"), HasAny("B", "C"), options=[OptionFilter(Opt, 0)]), - Or(Has("X"), CanReachRegion("Y"), options=[OptionFilter(Opt, 1)]), +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 "shovel" operator `<<` as shorthand: + +```python +common_rule = Has("A") +easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)] +common_rule_only_on_easy = common_rule << easy_filter +``` + ### Disabling caching 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 can disable the caching system entirely by setting the `rule_caching_enabled` class property to `False` on your world: From bd756995dbc157b5879876e8fd66f45fd00684cf Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 19:12:04 -0500 Subject: [PATCH 096/135] kill resolve_rule --- rule_builder.py | 30 ++++++++++++-------------- test/general/test_rule_builder.py | 36 +++++++++++++++---------------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/rule_builder.py b/rule_builder.py index 97319dc63f5f..b3dfa59bfd92 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -55,8 +55,8 @@ def __init__(self, multiworld: MultiWorld, player: int) -> None: self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) self.rule_entrance_dependencies = defaultdict(set) - self.true_rule = self.resolve_rule(True_()) - self.false_rule = self.resolve_rule(False_()) + self.true_rule = True_().resolve(self) + self.false_rule = False_().resolve(self) @classmethod def get_rule_cls(cls, name: str) -> type["Rule[Self]"]: @@ -70,10 +70,6 @@ def rule_from_dict(cls, data: Mapping[str, Any]) -> "Rule[Self]": rule_class = cls.get_rule_cls(name) return rule_class.from_dict(data, cls) - def resolve_rule(self, rule: "Rule[Self]") -> "Rule.Resolved": - """Returns a resolved rule registered with the caching system for this world""" - return rule.resolve(self) - def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: """Registers a rule's item, region, location, and entrance dependencies to this world instance""" if not self.rule_caching_enabled: @@ -123,7 +119,7 @@ def register_dependencies(self) -> None: def set_rule(self, spot: Location | Entrance, rule: "Rule[Self]") -> None: """Resolve and set a rule on a location or entrance""" - resolved_rule = self.resolve_rule(rule) + resolved_rule = rule.resolve(self) self.register_rule_dependencies(resolved_rule) spot.access_rule = resolved_rule if self.explicit_indirect_conditions and isinstance(spot, Entrance): @@ -135,12 +131,14 @@ def create_entrance( to_region: Region, rule: "Rule[Self] | 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""" + """Try to create an entrance between regions with the given rule, + skipping it if the rule resolves to False (unless force_creation is True)""" resolved_rule = None if rule is not None: - resolved_rule = self.resolve_rule(rule) - if resolved_rule.always_false: + resolved_rule = rule.resolve(self) + if resolved_rule.always_false and not force_creation: return None self.register_rule_dependencies(resolved_rule) @@ -153,7 +151,7 @@ def create_entrance( def set_completion_rule(self, rule: "Rule[Self]") -> None: """Set the completion rule for this world""" - resolved_rule = self.resolve_rule(rule) + resolved_rule = rule.resolve(self) self.register_rule_dependencies(resolved_rule) self.multiworld.completion_condition[self.player] = resolved_rule self.completion_rule = resolved_rule @@ -582,7 +580,7 @@ def __init__(self, *children: Rule[TWorld], options: Iterable[OptionFilter[Any]] @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - children = [world.resolve_rule(c) for c in self.children] + children = [c.resolve(world) for c in self.children] return self.Resolved(tuple(children), player=world.player, caching_enabled=world.rule_caching_enabled) @override @@ -667,7 +665,7 @@ class And(NestedRule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - children_to_process = [world.resolve_rule(c) for c in self.children] + children_to_process = [c.resolve(world) for c in self.children] clauses: list[Rule.Resolved] = [] items: dict[str, int] = {} true_rule: Rule.Resolved | None = None @@ -750,7 +748,7 @@ class Or(NestedRule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - children_to_process = [world.resolve_rule(c) for c in self.children] + children_to_process = [c.resolve(world) for c in self.children] clauses: list[Rule.Resolved] = [] items: dict[str, int] = {} @@ -834,7 +832,7 @@ class WrapperRule(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( - world.resolve_rule(self.child), + self.child.resolve(world), player=world.player, caching_enabled=world.rule_caching_enabled, ) @@ -920,7 +918,7 @@ class Filtered(WrapperRule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - return world.resolve_rule(self.child) + return self.child.resolve(world) @dataclasses.dataclass() diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 40a492f3f566..048d4bd494d1 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -191,7 +191,7 @@ def test_simplify(self) -> None: world = multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) rule, expected = self.rules - resolved_rule = world.resolve_rule(rule) + resolved_rule = rule.resolve(world) self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") @@ -211,22 +211,22 @@ def test_option_filtering(self) -> None: rule = Or(Has("A", options=[OptionFilter(ToggleOption, 0)]), Has("B", options=[OptionFilter(ToggleOption, 1)])) self.world.options.toggle_option.value = 0 - self.assertEqual(self.world.resolve_rule(rule), Has.Resolved("A", player=1)) + self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) self.world.options.toggle_option.value = 1 - self.assertEqual(self.world.resolve_rule(rule), Has.Resolved("B", player=1)) + self.assertEqual(rule.resolve(self.world), Has.Resolved("B", player=1)) def test_gt_filtering(self) -> None: rule = Or(Has("A", options=[OptionFilter(ChoiceOption, 1, operator="gt")]), False_()) self.world.options.choice_option.value = 0 - self.assertEqual(self.world.resolve_rule(rule), False_.Resolved(player=1)) + self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) self.world.options.choice_option.value = 1 - self.assertEqual(self.world.resolve_rule(rule), False_.Resolved(player=1)) + self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) self.world.options.choice_option.value = 2 - self.assertEqual(self.world.resolve_rule(rule), Has.Resolved("A", player=1)) + self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) @classvar_matrix( @@ -308,7 +308,7 @@ def test_has_all_hash(self) -> None: rule1 = HasAll("1", "2") rule2 = HasAll("2", "2", "2", "1") - self.assertEqual(hash(world.resolve_rule(rule1)), hash(world.resolve_rule(rule2))) + self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world))) class TestCaching(unittest.TestCase): @@ -499,19 +499,19 @@ def setUp(self) -> None: def test_true(self) -> None: rule = True_() - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertTrue(resolved_rule(self.state)) def test_false(self) -> None: rule = False_() - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) def test_has(self) -> None: rule = Has("Item 1") - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) item = self.world.create_item("Item 1") @@ -522,7 +522,7 @@ def test_has(self) -> None: def test_has_all(self) -> None: rule = HasAll("Item 1", "Item 2") - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) item1 = self.world.create_item("Item 1") @@ -537,7 +537,7 @@ def test_has_all(self) -> None: def test_has_any(self) -> None: item_names = ("Item 1", "Item 2") rule = HasAny(*item_names) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) @@ -550,7 +550,7 @@ def test_has_any(self) -> None: def test_has_all_counts(self) -> None: rule = HasAllCounts({"Item 1": 1, "Item 2": 2}) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) item1 = self.world.create_item("Item 1") @@ -568,7 +568,7 @@ def test_has_all_counts(self) -> None: def test_has_any_count(self) -> None: item_counts = {"Item 1": 1, "Item 2": 2} rule = HasAnyCount(item_counts) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) for item_name, count in item_counts.items(): @@ -583,7 +583,7 @@ def test_has_any_count(self) -> None: def test_has_from_list(self) -> None: item_names = ("Item 1", "Item 2", "Item 3") rule = HasFromList(*item_names, count=2) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) @@ -604,7 +604,7 @@ def test_has_from_list(self) -> None: def test_has_from_list_unique(self) -> None: item_names = ("Item 1", "Item 1", "Item 2") rule = HasFromListUnique(*item_names, count=2) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) self.assertFalse(resolved_rule(self.state)) @@ -625,7 +625,7 @@ def test_has_from_list_unique(self) -> None: def test_has_group(self) -> None: rule = HasGroup("Group 1", count=2) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) items: list[Item] = [] @@ -641,7 +641,7 @@ def test_has_group(self) -> None: def test_has_group_unique(self) -> None: rule = HasGroupUnique("Group 1", count=2) - resolved_rule = self.world.resolve_rule(rule) + resolved_rule = rule.resolve(self.world) self.world.register_rule_dependencies(resolved_rule) items: list[Item] = [] From 0706b448bc32ba10a2032d429c8f53da534da5f6 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 19:15:39 -0500 Subject: [PATCH 097/135] kill true_rule and false_rule --- docs/rule builder.md | 2 -- rule_builder.py | 26 +++++++++----------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 522f92cae5c9..c31c92c9e8f1 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -423,8 +423,6 @@ These are properties and helpers that are available to you in your world. #### Properties - `completion_rule: Rule.Resolved | None`: The resolved rule used for the completion condition of this world as set by `set_completion_rule` -- `true_rule: Rule.Resolved`: A pre-resolved rule for this player that is equal to `True_()` -- `false_rule: Rule.Resolved`: A pre-resolved rule for this player that is equal to `False_()` - `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name - `rule_caching_enabled: bool`: A boolean value to enable or disable rule caching for this world diff --git a/rule_builder.py b/rule_builder.py index b3dfa59bfd92..4f6991fa5b7e 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -35,12 +35,6 @@ class RuleWorldMixin(World): completion_rule: "Rule.Resolved | None" = None """The resolved rule used for the completion condition of this world""" - true_rule: "Rule.Resolved" - """A pre-initialized rule for this world that always returns True""" - - false_rule: "Rule.Resolved" - """A pre-initialized rule for this world that always returns False""" - item_mapping: ClassVar[dict[str, str]] = {} """A mapping of actual item name to logical item name. Useful when there are multiple versions of a collected item but the logic only uses one. For example: @@ -55,8 +49,6 @@ def __init__(self, multiworld: MultiWorld, player: int) -> None: self.rule_region_dependencies = defaultdict(set) self.rule_location_dependencies = defaultdict(set) self.rule_entrance_dependencies = defaultdict(set) - self.true_rule = True_().resolve(self) - self.false_rule = False_().resolve(self) @classmethod def get_rule_cls(cls, name: str) -> type["Rule[Self]"]: @@ -378,7 +370,7 @@ def resolve(self, world: TWorld) -> "Resolved": """Resolve a rule with the given world""" for option_filter in self.options: if not option_filter.check(world.options): - return world.false_rule + return False_().resolve(world) return self._instantiate(world) def to_dict(self) -> dict[str, Any]: @@ -698,7 +690,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: clauses.append(child) if not clauses and not items: - return true_rule or world.false_rule + return true_rule or False_().resolve(world) if len(items) == 1: item, count = next(iter(items.items())) @@ -778,7 +770,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: clauses.append(child) if not clauses and not items: - return world.false_rule + return False_().resolve(world) if len(items) == 1: item, count = next(iter(items.items())) @@ -1003,7 +995,7 @@ def __init__(self, *item_names: str, options: Iterable[OptionFilter[Any]] = ()) def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_all - return world.true_rule + return True_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player, caching_enabled=world.rule_caching_enabled) @@ -1112,7 +1104,7 @@ def __init__(self, *item_names: str, options: Iterable[OptionFilter[Any]] = ()) def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_any - return world.false_rule + return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved(self.item_names, player=world.player, caching_enabled=world.rule_caching_enabled) @@ -1217,7 +1209,7 @@ class HasAllCounts(Rule[TWorld], game="Archipelago"): def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 0: # match state.has_all_counts - return world.true_rule + return True_().resolve(world) if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) @@ -1322,7 +1314,7 @@ class HasAnyCount(Rule[TWorld], game="Archipelago"): def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_counts) == 0: # match state.has_any_count - return world.false_rule + return False_().resolve(world) if len(self.item_counts) == 1: item = next(iter(self.item_counts)) return Has(item, self.item_counts[item]).resolve(world) @@ -1435,7 +1427,7 @@ def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFil def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_from_list - return world.false_rule + return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0], self.count).resolve(world) return self.Resolved( @@ -1563,7 +1555,7 @@ def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFil def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0 or len(self.item_names) < self.count: # match state.has_from_list_unique - return world.false_rule + return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) return self.Resolved( From 9a196e9a511b42906cfc6e1107ac1d7115b1c957 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 21:16:53 -0500 Subject: [PATCH 098/135] move helpers to base classes --- BaseClasses.py | 30 +++++++++------- rule_builder.py | 85 ++++++++------------------------------------- worlds/AutoWorld.py | 70 ++++++++++++++++++++++++++++++++++--- 3 files changed, 97 insertions(+), 88 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index 53808820dc4d..baaafb2fc50f 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -8,10 +8,10 @@ import warnings from argparse import Namespace from collections import Counter, deque, defaultdict -from collections.abc import Collection, MutableSequence +from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, MutableSequence, Set from enum import IntEnum, IntFlag -from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, - Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING, Literal, overload) +from typing import (AbstractSet, Any, ClassVar, Dict, List, Literal, NamedTuple, + Optional, Protocol, Tuple, Union, TYPE_CHECKING, overload) import dataclasses from typing_extensions import NotRequired, TypedDict @@ -85,7 +85,7 @@ class MultiWorld(): local_items: Dict[int, Options.LocalItems] non_local_items: Dict[int, Options.NonLocalItems] progression_balancing: Dict[int, Options.ProgressionBalancing] - completion_condition: Dict[int, Callable[[CollectionState], bool]] + completion_condition: Dict[int, AccessRule] indirect_connections: Dict[Region, Set[Entrance]] exclude_locations: Dict[int, Options.ExcludeLocations] priority_locations: Dict[int, Options.PriorityLocations] @@ -1175,13 +1175,17 @@ def set_item(self, item: str, player: int, count: int) -> None: self.prog_items[player][item] = count +AccessRule = Callable[[CollectionState], bool] +DEFAULT_ACCESS_RULE: AccessRule = staticmethod(lambda state: True) + + class EntranceType(IntEnum): ONE_WAY = 1 TWO_WAY = 2 class Entrance: - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + access_rule: AccessRule = DEFAULT_ACCESS_RULE hide_path: bool = False player: int name: str @@ -1368,7 +1372,7 @@ def add_event( self, location_name: str, item_name: str | None = None, - rule: Callable[[CollectionState], bool] | None = None, + rule: AccessRule | None = None, location_type: type[Location] | None = None, item_type: type[Item] | None = None, show_in_spoiler: bool = True, @@ -1407,7 +1411,7 @@ def add_event( return event_item def connect(self, connecting_region: Region, name: Optional[str] = None, - rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance: + rule: Optional[AccessRule] = None) -> Entrance: """ Connects this Region to another Region, placing the provided rule on the connection. @@ -1441,7 +1445,7 @@ def create_er_target(self, name: str) -> Entrance: return entrance def add_exits(self, exits: Iterable[str] | Mapping[str, str | None], - rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]: + rules: Mapping[str, AccessRule] | None = None) -> List[Entrance]: """ Connects current region to regions in exit dictionary. Passed region names must exist first. @@ -1480,7 +1484,7 @@ class Location: show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) - access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) + access_rule: AccessRule = DEFAULT_ACCESS_RULE item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item: Optional[Item] = None @@ -1557,7 +1561,7 @@ class ItemClassification(IntFlag): skip_balancing = 0b01000 """ should technically never occur on its own Item that is logically relevant, but progression balancing should not touch. - + Possible reasons for why an item should not be pulled ahead by progression balancing: 1. This item is quite insignificant, so pulling it earlier doesn't help (currency/etc.) 2. It is important for the player experience that this item is evenly distributed in the seed (e.g. goal items) """ @@ -1565,13 +1569,13 @@ class ItemClassification(IntFlag): deprioritized = 0b10000 """ Should technically never occur on its own. Will not be considered for priority locations, - unless Priority Locations Fill runs out of regular progression items before filling all priority locations. - + unless Priority Locations Fill runs out of regular progression items before filling all priority locations. + Should be used for items that would feel bad for the player to find on a priority location. Usually, these are items that are plentiful or insignificant. """ progression_deprioritized_skip_balancing = 0b11001 - """ Since a common case of both skip_balancing and deprioritized is "insignificant progression", + """ Since a common case of both skip_balancing and deprioritized is "insignificant progression", these items often want both flags. """ progression_skip_balancing = 0b01001 # only progression gets balanced diff --git a/rule_builder.py b/rule_builder.py index 4f6991fa5b7e..b37515e5957c 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -7,15 +7,19 @@ from typing_extensions import TypeVar, dataclass_transform, override -from BaseClasses import CollectionState, Entrance, Item, Location, MultiWorld, Region +from BaseClasses import CollectionState, Item, MultiWorld, Region from NetUtils import JSONMessagePart from Options import CommonOptions, Option if TYPE_CHECKING: from worlds.AutoWorld import World + + TWorld = TypeVar("TWorld", bound=World, contravariant=True, default=World) # noqa: PLC0105 else: World = object + TWorld = TypeVar("TWorld", contravariant=True) # noqa: PLC0105 + class RuleWorldMixin(World): """A World mixin that provides helpers for interacting with the rule builder""" @@ -32,9 +36,6 @@ class RuleWorldMixin(World): rule_entrance_dependencies: dict[str, set[int]] """A mapping of entrance name to set of rule ids""" - completion_rule: "Rule.Resolved | None" = None - """The resolved rule used for the completion condition of this world""" - item_mapping: ClassVar[dict[str, str]] = {} """A mapping of actual item name to logical item name. Useful when there are multiple versions of a collected item but the logic only uses one. For example: @@ -50,20 +51,8 @@ def __init__(self, multiworld: MultiWorld, player: int) -> None: self.rule_location_dependencies = defaultdict(set) self.rule_entrance_dependencies = defaultdict(set) - @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) - + @override def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: - """Registers a rule's item, region, location, and entrance dependencies to this world instance""" if not self.rule_caching_enabled: return for item_name, rule_ids in resolved_rule.item_dependencies().items(): @@ -75,11 +64,6 @@ def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): self.rule_entrance_dependencies[entrance_name] |= rule_ids - def register_rule_connections(self, resolved_rule: "Rule.Resolved", entrance: Entrance) -> None: - """Register indirect connections for this entrance based on the rule's dependencies""" - for indirect_region in resolved_rule.region_dependencies().keys(): - self.multiworld.register_indirect_condition(self.get_region(indirect_region), entrance) - def register_dependencies(self) -> None: """Register all rules that depend on locations or entrances with their dependencies""" if not self.rule_caching_enabled: @@ -109,45 +93,6 @@ def register_dependencies(self) -> None: for region_name in entrance.access_rule.region_dependencies(): self.rule_region_dependencies[region_name] |= rule_ids - def set_rule(self, spot: Location | Entrance, rule: "Rule[Self]") -> None: - """Resolve and set a rule on a location or entrance""" - resolved_rule = rule.resolve(self) - self.register_rule_dependencies(resolved_rule) - spot.access_rule = resolved_rule - if self.explicit_indirect_conditions and isinstance(spot, Entrance): - self.register_rule_connections(resolved_rule, spot) - - def create_entrance( - self, - from_region: Region, - to_region: Region, - rule: "Rule[Self] | 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)""" - resolved_rule = None - if rule is not None: - resolved_rule = rule.resolve(self) - if resolved_rule.always_false and not force_creation: - return None - self.register_rule_dependencies(resolved_rule) - - entrance = from_region.connect(to_region, name) - if resolved_rule: - entrance.access_rule = resolved_rule - if resolved_rule is not None: - self.register_rule_connections(resolved_rule, entrance) - return entrance - - def set_completion_rule(self, rule: "Rule[Self]") -> None: - """Set the completion rule for this world""" - resolved_rule = rule.resolve(self) - self.register_rule_dependencies(resolved_rule) - self.multiworld.completion_condition[self.player] = resolved_rule - self.completion_rule = resolved_rule - @override def collect(self, state: CollectionState, item: Item) -> bool: changed = super().collect(state, item) @@ -203,8 +148,6 @@ def reached_region(self, state: CollectionState, region: Region) -> None: player_results.pop(rule_id, None) -TWorld = TypeVar("TWorld", bound=RuleWorldMixin, contravariant=True, default=RuleWorldMixin) # noqa: PLC0105 - Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] OPERATORS: dict[Operator, Callable[..., bool]] = { @@ -385,7 +328,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: """Returns a new instance of this rule from a serialized dict representation""" options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(**data.get("args", {}), options=options) @@ -584,7 +527,7 @@ def to_dict(self) -> dict[str, Any]: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: children = [world_cls.rule_from_dict(c) for c in data.get("children", ())] options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*children, options=options) @@ -838,7 +781,7 @@ def to_dict(self) -> dict[str, Any]: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: child = data.get("child") if child is None: raise ValueError("Child rule cannot be None") @@ -1002,7 +945,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1111,7 +1054,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1439,7 +1382,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1567,7 +1510,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -2011,7 +1954,7 @@ def __str__(self) -> str: DEFAULT_RULES = { - rule_name: cast(type[Rule[RuleWorldMixin]], rule_class) + rule_name: cast(type[Rule[World]], rule_class) for rule_name, rule_class in locals().items() if isinstance(rule_class, type) and issubclass(rule_class, Rule) and rule_class is not Rule } diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index fbeef3bf8a6e..6c0826b7cf00 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -5,17 +5,18 @@ 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 import CustomRuleRegister, Rule from Utils import Version if TYPE_CHECKING: - from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance + from BaseClasses import AccessRule, MultiWorld, Item, Location, Tutorial, Region from NetUtils import GamesPackage, MultiData from settings import Group @@ -344,6 +345,9 @@ class World(metaclass=AutoWorldRegister): world_version: ClassVar[Version] = Version(0, 0, 0) """Optional world version loaded from archipelago.json""" + rule_caching_enabled: ClassVar[bool] = False + """Enable or disable the rule result caching system""" + def __init__(self, multiworld: "MultiWorld", player: int): assert multiworld is not None self.multiworld = multiworld @@ -590,6 +594,64 @@ def get_data_package_data(cls) -> "GamesPackage": 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: AccessRule | 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: AccessRule | 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: AccessRule | 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 From bd0cf0e0318827e1260a39b9a106bcf8bab2c6c1 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 21:40:03 -0500 Subject: [PATCH 099/135] fix compat --- worlds/astalon/logic/custom_rules.py | 4 ++-- worlds/astalon/test/test_locations.py | 3 +++ worlds/astalon/test/test_rules.py | 10 +++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 243845f6de76..ab821aec7276 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -220,7 +220,7 @@ class HasGoal(rule_builder.Rule[AstalonWorldBase], game=GAME_NAME): @override def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: if world.options.goal.value != Goal.option_eye_hunt: - return world.true_rule + return rule_builder.True_().resolve(world) return Has.Resolved( Eye.GOLD.value, count=world.options.additional_eyes_required.value, @@ -241,7 +241,7 @@ def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: player=world.player, caching_enabled=world.rule_caching_enabled, ) - return world.false_rule + return rule_builder.False_().resolve(world) class Resolved(rule_builder.WrapperRule.Resolved): @override diff --git a/worlds/astalon/test/test_locations.py b/worlds/astalon/test/test_locations.py index 16d1dc997954..44a42d14e43f 100644 --- a/worlds/astalon/test/test_locations.py +++ b/worlds/astalon/test/test_locations.py @@ -1,3 +1,5 @@ +from typing import override + from ..items import BlueDoor, Eye, KeyItem from ..locations import LocationName from .bases import AstalonTestBase @@ -13,6 +15,7 @@ class LocationsTest(AstalonTestBase): } @property + @override def run_default_tests(self) -> bool: return False diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py index 048bd6436125..4b35753db1cd 100644 --- a/worlds/astalon/test/test_rules.py +++ b/worlds/astalon/test/test_rules.py @@ -1,3 +1,5 @@ +from typing import override + from rule_builder import And, OptionFilter, Or, True_ from ..items import BlueDoor, Crystal @@ -11,6 +13,7 @@ class RuleHashTest(AstalonTestBase): auto_construct = False @property + @override def run_default_tests(self) -> bool: return False @@ -45,6 +48,7 @@ class RuleResolutionTest(AstalonTestBase): } @property + @override def run_default_tests(self) -> bool: return False @@ -65,13 +69,13 @@ def test_upper_path_rule_easy(self) -> None: expected = Or.Resolved( ( HasAll.Resolved( - ("Bram", "Morning Star", "Blue Door (Gorgon Tomb - Ring of the Ancients)"), + ("Blue Door (Gorgon Tomb - Ring of the Ancients)", "Bram", "Morning Star"), player=self.player, ), - HasAll.Resolved(("Zeek", "Magic Block"), player=self.player), + HasAll.Resolved(("Magic Block", "Zeek"), player=self.player), Has.Resolved("Crystal (Gorgon Tomb - RotA)", player=self.player), ), player=self.player, ) - instance = self.world.resolve_rule(rule) + instance = rule.resolve(self.world) self.assertEqual(instance, expected, f"\n{instance}\n{expected}") From a6cbe07a4d7ed8c30c8c1d8c0cc4576a832f63c0 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 21:57:40 -0500 Subject: [PATCH 100/135] fix import --- worlds/astalon/test/test_locations.py | 2 +- worlds/astalon/test/test_rules.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/astalon/test/test_locations.py b/worlds/astalon/test/test_locations.py index 44a42d14e43f..69ebd53d5710 100644 --- a/worlds/astalon/test/test_locations.py +++ b/worlds/astalon/test/test_locations.py @@ -1,4 +1,4 @@ -from typing import override +from typing_extensions import override from ..items import BlueDoor, Eye, KeyItem from ..locations import LocationName diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py index 4b35753db1cd..d298935c62ec 100644 --- a/worlds/astalon/test/test_rules.py +++ b/worlds/astalon/test/test_rules.py @@ -1,4 +1,4 @@ -from typing import override +from typing_extensions import override from rule_builder import And, OptionFilter, Or, True_ From dd63d31d1aaff17c603cea917e14fc04e6bf8663 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 22:46:48 -0500 Subject: [PATCH 101/135] update docs --- docs/rule builder.md | 38 +++++++++++++++++++------------------- rule_builder.py | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index c31c92c9e8f1..aa3b2204ae98 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -13,19 +13,20 @@ 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.Rule`. These are what you write for your laogic. They can be combined and take into account your world's options. There are a number of default rules listed blow, 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.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. -1. The rule builder world mixin class `RuleWorldMixin`, which is a class your world should inherit. It adds a number of helper functions related to assigning and resolving rules. +1. Resolved rules, which are classes that inherit from `rule_builder.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 +1. The optional rule builder world mixin class `RuleWorldMixin`, which is a class your world can inherit. It adds a number of helper functions related to assigning and resolving rules. ## Usage -The rule builder provides a `RuleWorldMixin` for your `World` class that provides some helpers for you. +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 -class MyWorld(RuleWorldMixin, World): - game = "My Game" +# In your world's create_regions method +location = MyWorldLocation(...) +self.set_rule(location, Has("A Big Gun")) ``` -The rule builder comes with a few rules by default: +The rule builder comes with a number of rules by default: - `True_`: Always returns true - `False_`: Always returns false @@ -54,13 +55,13 @@ rule = Has("Movement ability") | HasAll("Key 1", "Key 2") ### Assigning rules -When assigning the rule you must use the `set_rule` helper added by the rule mixin to correctly resolve and register the rule. +When assigning the rule you must use the `set_rule` helper to correctly resolve and register the rule. ```python 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. +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) @@ -74,8 +75,6 @@ You can also set a rule for your world's completion condition: self.set_completion_rule(rule) ``` -If your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. - ### 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. If you want a comparison that isn't equals, you can specify with the `operator` arguemnt. @@ -130,18 +129,20 @@ easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)] common_rule_only_on_easy = common_rule << easy_filter ``` -### Disabling caching +### Enabling caching -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 can disable the caching system entirely by setting the `rule_caching_enabled` class property to `False` on your world: +The rule builder provides a `RuleWorldMixin` for your `World` class that enables caching on your rules. ```python class MyWorld(RuleWorldMixin, World): - rule_caching_enabled = False + game = "My Game" ``` -You'll have to benchmark your own world to see if it should be disabled 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 disabled or not. + +If you enabled caching and your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. -### Item name mapping +## 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. @@ -251,14 +252,14 @@ The default `CanReachEntrance` rule defines this function already. ### Cache control -By default your custom rule will work through the cache system as any other rule. There are two class attributes on the `Resolved` class you can override to change this behaviour. +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 behaviour. - `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=world.rule_caching_enabled` in your `_instantiate` function when creating resolved rule instances. +- Ensure you are passing `caching_enabled=world.rule_caching_enabled` in your `_instantiate` function when creating resolved rule instances if your world has caching enabled. - 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 so they get registered with the world's caching system. @@ -422,7 +423,6 @@ These are properties and helpers that are available to you in your world. #### Properties -- `completion_rule: Rule.Resolved | None`: The resolved rule used for the completion condition of this world as set by `set_completion_rule` - `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name - `rule_caching_enabled: bool`: A boolean value to enable or disable rule caching for this world @@ -431,8 +431,8 @@ These are properties and helpers that are available to you in your world. - `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation - `register_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies - `set_rule(spot: Location | Entrance, rule: Rule)`: Resolve a rule, register its dependencies, and set it on the given location or entrance -- `create_entrance(from_region: Region, to_rengion: Region, rule: Rule | None, name: str | None = None)`: Attempt to create an entrance from `from_region` to `to_region`, skipping creation if `rule` is defined and evaluates to `False_()` - `set_completion_rule(rule: Rule)`: Sets the completion condition for this world +- `create_entrance(from_region: Region, to_rengion: 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` ### Rule API diff --git a/rule_builder.py b/rule_builder.py index b37515e5957c..00cf5654dc2d 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -388,7 +388,7 @@ class Resolved(metaclass=CustomRuleRegister): player: int """The player this rule is for""" - caching_enabled: bool = dataclasses.field(repr=False, default=True, kw_only=True) + caching_enabled: bool = dataclasses.field(repr=False, default=False, kw_only=True) """If the world this rule is for has caching enabled""" force_recalculate: ClassVar[bool] = False From 11b4e66786bff832d0f0347e7dc3102b93b0f2ff Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 22:47:34 -0500 Subject: [PATCH 102/135] I really should finish all of my --- docs/rule builder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index aa3b2204ae98..41a0a70deb99 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -13,7 +13,7 @@ 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.Rule`. These are what you write for your laogic. They can be combined and take into account your world's options. There are a number of default rules listed blow, 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.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 +1. Resolved rules, which are classes that inherit from `rule_builder.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 mixin class `RuleWorldMixin`, which is a class your world can inherit. It adds a number of helper functions related to assigning and resolving rules. ## Usage From 8597fc10ad49737fc427060200264243b74200dc Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 23:07:04 -0500 Subject: [PATCH 103/135] fix test --- test/general/test_rule_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 048d4bd494d1..f3ae920660b8 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -190,6 +190,7 @@ def test_simplify(self) -> None: multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) world = multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) + world.rule_caching_enabled = False # pyright: ignore[reportAttributeAccessIssue] rule, expected = self.rules resolved_rule = rule.resolve(world) self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") From 3fc8769c5d3e6690bae5f9fc400c8aedebd85712 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 11 Dec 2025 23:08:35 -0500 Subject: [PATCH 104/135] rename mixin --- docs/rule builder.md | 10 +++++----- rule_builder.py | 2 +- test/general/test_rule_builder.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 41a0a70deb99..d683281178ab 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -14,7 +14,7 @@ The rule builder consists of 3 main parts: 1. The rules, which are classes that inherit from `rule_builder.Rule`. These are what you write for your laogic. They can be combined and take into account your world's options. There are a number of default rules listed blow, 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.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 mixin class `RuleWorldMixin`, which is a class your world can inherit. It adds a number of helper functions related to assigning and resolving rules. +1. The optional rule builder world mixin class `CachedRuleBuilderMixin`, which is a class your world can inherit. It adds a number of helper functions related to assigning and resolving rules. ## Usage @@ -131,10 +131,10 @@ common_rule_only_on_easy = common_rule << easy_filter ### Enabling caching -The rule builder provides a `RuleWorldMixin` for your `World` class that enables caching on your rules. +The rule builder provides a `CachedRuleBuilderMixin` for your `World` class that enables caching on your rules. ```python -class MyWorld(RuleWorldMixin, World): +class MyWorld(CachedRuleBuilderMixin, World): game = "My Game" ``` @@ -149,7 +149,7 @@ If you have multiple real items that map to a single logic item, add a `item_map For example, if you have multiple `Currecy x` items on locations, but your rules only check a singlular logical `Currency` item, eg `Has("Currency", 1000)`, you'll want to map each numerical currency item to the single logical `Currency`. ```python -class MyWorld(RuleWorldMixin, World): +class MyWorld(CachedRuleBuilderMixin, World): item_mapping = { "Currency x10": "Currency", "Currency x50": "Currency", @@ -369,7 +369,7 @@ If your logic has been done in custom JSON first, you can define a `from_dict` c ```python class BasicLogicRule(Rule, game="My Game"): @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[RuleWorldMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[CachedRuleBuilderMixin]) -> Self: items = data.get("items", ()) return cls(*items) ``` diff --git a/rule_builder.py b/rule_builder.py index 00cf5654dc2d..328d6cd5cf9b 100644 --- a/rule_builder.py +++ b/rule_builder.py @@ -21,7 +21,7 @@ TWorld = TypeVar("TWorld", contravariant=True) # noqa: PLC0105 -class RuleWorldMixin(World): +class CachedRuleBuilderMixin(World): """A World mixin that provides helpers for interacting with the rule builder""" rule_item_dependencies: dict[str, set[int]] diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index f3ae920660b8..538a5d779c70 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -9,6 +9,7 @@ from Options import Choice, PerGameCommonOptions, Toggle from rule_builder import ( And, + CachedRuleBuilderMixin, CanReachEntrance, CanReachLocation, CanReachRegion, @@ -26,7 +27,6 @@ OptionFilter, Or, Rule, - RuleWorldMixin, True_, ) from test.general import setup_solo_multiworld @@ -68,7 +68,7 @@ class RuleBuilderWebWorld(WebWorld): tutorials = [] # noqa: RUF012 -class RuleBuilderWorld(RuleWorldMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] +class RuleBuilderWorld(CachedRuleBuilderMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] game = GAME web = RuleBuilderWebWorld() item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} From 71ed8c511e8510c7cdcffc34e06043b227c3d000 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Fri, 12 Dec 2025 12:05:53 -0500 Subject: [PATCH 105/135] fix typos --- docs/rule builder.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index d683281178ab..211d0d1cf1f6 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -1,6 +1,6 @@ # Rule Builder -This document describes the API provided for the rule builder. Using this API prvoides 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,7 +12,7 @@ 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.Rule`. These are what you write for your laogic. They can be combined and take into account your world's options. There are a number of default rules listed blow, 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. The rules, which are classes that inherit from `rule_builder.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 blow, 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.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 mixin class `CachedRuleBuilderMixin`, which is a class your world can inherit. It adds a number of helper functions related to assigning and resolving rules. @@ -77,7 +77,7 @@ 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. If you want a comparison that isn't equals, you can specify with the `operator` arguemnt. +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. If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed: @@ -89,7 +89,7 @@ The following operators are allowed: - `le`: `<=` - `contains`: `in` -To check if the player can reach a switch, or if they've receieved the switch item if switches are randomized: +To check if the player can reach a switch, or if they've received the switch item if switches are randomized: ```python rule = ( @@ -146,7 +146,7 @@ If you enabled caching and your rules use `CanReachLocation`, `CanReachEntrance` 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 `Currecy x` items on locations, but your rules only check a singlular 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` 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(CachedRuleBuilderMixin, World): @@ -252,7 +252,7 @@ The default `CanReachEntrance` rule defines this function already. ### 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 behaviour. +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. @@ -432,7 +432,7 @@ These are properties and helpers that are available to you in your world. - `register_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies - `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_rengion: 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` ### Rule API From 632aa22d893c74d7c2a4c95efb7cbcd842d30999 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 25 Dec 2025 01:52:39 -0500 Subject: [PATCH 106/135] refactor rule builder into folder for better imports --- rule_builder/__init__.py | 0 rule_builder/cached_world.py | 136 ++++++++++++++ rule_builder/options.py | 90 +++++++++ rule_builder.py => rule_builder/rules.py | 223 +---------------------- test/general/test_rule_builder.py | 10 +- worlds/AutoWorld.py | 2 +- 6 files changed, 238 insertions(+), 223 deletions(-) create mode 100644 rule_builder/__init__.py create mode 100644 rule_builder/cached_world.py create mode 100644 rule_builder/options.py rename rule_builder.py => rule_builder/rules.py (88%) diff --git a/rule_builder/__init__.py b/rule_builder/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rule_builder/cached_world.py b/rule_builder/cached_world.py new file mode 100644 index 000000000000..28fae7b44048 --- /dev/null +++ b/rule_builder/cached_world.py @@ -0,0 +1,136 @@ +from collections import defaultdict +from typing import ClassVar + +from typing_extensions import override + +from BaseClasses import CollectionState, Item, MultiWorld, Region +from worlds.AutoWorld import World + +from .rules import Rule + + +class CachedRuleBuilderWorld(World): + """A World subclass that provides helpers for interacting with the rule builder""" + + rule_item_dependencies: dict[str, set[int]] + """A mapping of item name to set of rule ids""" + + rule_region_dependencies: dict[str, set[int]] + """A mapping of region name to set of rule ids""" + + rule_location_dependencies: dict[str, set[int]] + """A mapping of location name to set of rule ids""" + + rule_entrance_dependencies: dict[str, set[int]] + """A mapping of entrance name to set of rule ids""" + + item_mapping: ClassVar[dict[str, str]] = {} + """A mapping of actual item name to logical item name. + Useful when there are multiple versions of a collected item but the logic only uses one. For example: + item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}""" + + rule_caching_enabled: ClassVar[bool] = True + """Enable or disable the rule result caching system""" + + def __init__(self, multiworld: MultiWorld, player: int) -> None: + super().__init__(multiworld, player) + self.rule_item_dependencies = defaultdict(set) + self.rule_region_dependencies = defaultdict(set) + self.rule_location_dependencies = defaultdict(set) + self.rule_entrance_dependencies = defaultdict(set) + + @override + def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None: + if not self.rule_caching_enabled: + return + for item_name, rule_ids in resolved_rule.item_dependencies().items(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name, rule_ids in resolved_rule.region_dependencies().items(): + self.rule_region_dependencies[region_name] |= rule_ids + for location_name, rule_ids in resolved_rule.location_dependencies().items(): + self.rule_location_dependencies[location_name] |= rule_ids + for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): + self.rule_entrance_dependencies[entrance_name] |= rule_ids + + def register_dependencies(self) -> None: + """Register all rules that depend on locations or entrances with their dependencies""" + if not self.rule_caching_enabled: + return + + for location_name, rule_ids in self.rule_location_dependencies.items(): + try: + location = self.get_location(location_name) + except KeyError: + continue + if not isinstance(location.access_rule, Rule.Resolved): + continue + for item_name in location.access_rule.item_dependencies(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name in location.access_rule.region_dependencies(): + self.rule_region_dependencies[region_name] |= rule_ids + + for entrance_name, rule_ids in self.rule_entrance_dependencies.items(): + try: + entrance = self.get_entrance(entrance_name) + except KeyError: + continue + if not isinstance(entrance.access_rule, Rule.Resolved): + continue + for item_name in entrance.access_rule.item_dependencies(): + self.rule_item_dependencies[item_name] |= rule_ids + for region_name in entrance.access_rule.region_dependencies(): + self.rule_region_dependencies[region_name] |= rule_ids + + @override + def collect(self, state: CollectionState, item: Item) -> bool: + changed = super().collect(state, item) + if changed and self.rule_caching_enabled and self.rule_item_dependencies: + player_results = state.rule_cache[self.player] + mapped_name = self.item_mapping.get(item.name, "") + rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] + for rule_id in rule_ids: + if player_results.get(rule_id, None) is False: + del player_results[rule_id] + + return changed + + @override + def remove(self, state: CollectionState, item: Item) -> bool: + changed = super().remove(state, item) + if not changed or not self.rule_caching_enabled: + return changed + + player_results = state.rule_cache[self.player] + if self.rule_item_dependencies: + mapped_name = self.item_mapping.get(item.name, "") + rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] + for rule_id in rule_ids: + player_results.pop(rule_id, None) + + # clear all region dependent caches as none can be trusted + if self.rule_region_dependencies: + for rule_ids in self.rule_region_dependencies.values(): + for rule_id in rule_ids: + player_results.pop(rule_id, None) + + # clear all location dependent caches as they may have lost region access + if self.rule_location_dependencies: + for rule_ids in self.rule_location_dependencies.values(): + for rule_id in rule_ids: + player_results.pop(rule_id, None) + + # clear all entrance dependent caches as they may have lost region access + if self.rule_entrance_dependencies: + for rule_ids in self.rule_entrance_dependencies.values(): + for rule_id in rule_ids: + player_results.pop(rule_id, None) + + return changed + + @override + def reached_region(self, state: CollectionState, region: Region) -> None: + super().reached_region(state, region) + if self.rule_caching_enabled and self.rule_region_dependencies: + player_results = state.rule_cache[self.player] + for rule_id in self.rule_region_dependencies[region.name]: + player_results.pop(rule_id, None) diff --git a/rule_builder/options.py b/rule_builder/options.py new file mode 100644 index 000000000000..23aff25d0b60 --- /dev/null +++ b/rule_builder/options.py @@ -0,0 +1,90 @@ +import dataclasses +import importlib +import operator +from collections.abc import Callable, Iterable +from typing import Any, Generic, Literal, Self, cast + +from typing_extensions import TypeVar, override + +from Options import CommonOptions, Option + +Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] + +OPERATORS: dict[Operator, Callable[..., bool]] = { + "eq": operator.eq, + "ne": operator.ne, + "gt": operator.gt, + "lt": operator.lt, + "ge": operator.ge, + "le": operator.le, + "contains": operator.contains, +} +operator_strings: dict[Operator, str] = { + "eq": "==", + "ne": "!=", + "gt": ">", + "lt": "<", + "ge": ">=", + "le": "<=", +} + +T = TypeVar("T") + + +@dataclasses.dataclass(frozen=True) +class OptionFilter(Generic[T]): + option: type[Option[T]] + value: T + operator: Operator = "eq" + + def to_dict(self) -> dict[str, Any]: + """Returns a JSON compatible dict representation of this option filter""" + return { + "option": f"{self.option.__module__}.{self.option.__name__}", + "value": self.value, + "operator": self.operator, + } + + def check(self, options: CommonOptions) -> bool: + """Tests the given options dataclass to see if it passes this option filter""" + option_name = next( + (name for name, cls in options.__class__.type_hints.items() if cls is self.option), + None, + ) + if option_name is None: + raise ValueError(f"Cannot find option {self.option.__name__} in options class {options.__class__.__name__}") + opt = cast(Option[Any] | None, getattr(options, option_name, None)) + if opt is None: + raise ValueError(f"Invalid option: {option_name}") + + return OPERATORS[self.operator](opt.value, self.value) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Returns a new OptionFilter instance from a dict representation""" + if "option" not in data or "value" not in data: + raise ValueError("Missing required value and/or option") + + option_path = data["option"] + try: + option_mod_name, option_cls_name = option_path.rsplit(".", 1) + option_module = importlib.import_module(option_mod_name) + option = getattr(option_module, option_cls_name, None) + except (ValueError, ImportError) as e: + raise ValueError(f"Cannot parse option '{option_path}'") from e + if option is None or not issubclass(option, Option): + raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass") + + value = data["value"] + operator = data.get("operator", "eq") + return cls(option=cast(type[Option[Any]], option), value=value, operator=operator) + + @classmethod + def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple["OptionFilter[Any]", ...]: + """Returns a tuple of OptionFilters instances from an iterable of dict representations""" + return tuple(cls.from_dict(o) for o in data) + + @override + def __str__(self) -> str: + op = operator_strings.get(self.operator, self.operator) + return f"{self.option.__name__} {op} {self.value}" diff --git a/rule_builder.py b/rule_builder/rules.py similarity index 88% rename from rule_builder.py rename to rule_builder/rules.py index 328d6cd5cf9b..21eab8468b7c 100644 --- a/rule_builder.py +++ b/rule_builder/rules.py @@ -1,15 +1,13 @@ import dataclasses -import importlib -import operator -from collections import defaultdict from collections.abc import Callable, Iterable, Mapping -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, Never, Self, cast +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Never, Self, cast from typing_extensions import TypeVar, dataclass_transform, override -from BaseClasses import CollectionState, Item, MultiWorld, Region +from BaseClasses import CollectionState from NetUtils import JSONMessagePart -from Options import CommonOptions, Option + +from .options import OptionFilter if TYPE_CHECKING: from worlds.AutoWorld import World @@ -18,216 +16,7 @@ else: World = object - TWorld = TypeVar("TWorld", contravariant=True) # noqa: PLC0105 - - -class CachedRuleBuilderMixin(World): - """A World mixin that provides helpers for interacting with the rule builder""" - - rule_item_dependencies: dict[str, set[int]] - """A mapping of item name to set of rule ids""" - - rule_region_dependencies: dict[str, set[int]] - """A mapping of region name to set of rule ids""" - - rule_location_dependencies: dict[str, set[int]] - """A mapping of location name to set of rule ids""" - - rule_entrance_dependencies: dict[str, set[int]] - """A mapping of entrance name to set of rule ids""" - - item_mapping: ClassVar[dict[str, str]] = {} - """A mapping of actual item name to logical item name. - Useful when there are multiple versions of a collected item but the logic only uses one. For example: - item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}""" - - rule_caching_enabled: ClassVar[bool] = True - """Enable or disable the rule result caching system""" - - def __init__(self, multiworld: MultiWorld, player: int) -> None: - super().__init__(multiworld, player) - self.rule_item_dependencies = defaultdict(set) - self.rule_region_dependencies = defaultdict(set) - self.rule_location_dependencies = defaultdict(set) - self.rule_entrance_dependencies = defaultdict(set) - - @override - def register_rule_dependencies(self, resolved_rule: "Rule.Resolved") -> None: - if not self.rule_caching_enabled: - return - for item_name, rule_ids in resolved_rule.item_dependencies().items(): - self.rule_item_dependencies[item_name] |= rule_ids - for region_name, rule_ids in resolved_rule.region_dependencies().items(): - self.rule_region_dependencies[region_name] |= rule_ids - for location_name, rule_ids in resolved_rule.location_dependencies().items(): - self.rule_location_dependencies[location_name] |= rule_ids - for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): - self.rule_entrance_dependencies[entrance_name] |= rule_ids - - def register_dependencies(self) -> None: - """Register all rules that depend on locations or entrances with their dependencies""" - if not self.rule_caching_enabled: - return - - for location_name, rule_ids in self.rule_location_dependencies.items(): - try: - location = self.get_location(location_name) - except KeyError: - continue - if not isinstance(location.access_rule, Rule.Resolved): - continue - for item_name in location.access_rule.item_dependencies(): - self.rule_item_dependencies[item_name] |= rule_ids - for region_name in location.access_rule.region_dependencies(): - self.rule_region_dependencies[region_name] |= rule_ids - - for entrance_name, rule_ids in self.rule_entrance_dependencies.items(): - try: - entrance = self.get_entrance(entrance_name) - except KeyError: - continue - if not isinstance(entrance.access_rule, Rule.Resolved): - continue - for item_name in entrance.access_rule.item_dependencies(): - self.rule_item_dependencies[item_name] |= rule_ids - for region_name in entrance.access_rule.region_dependencies(): - self.rule_region_dependencies[region_name] |= rule_ids - - @override - def collect(self, state: CollectionState, item: Item) -> bool: - changed = super().collect(state, item) - if changed and self.rule_caching_enabled and self.rule_item_dependencies: - player_results = state.rule_cache[self.player] - mapped_name = self.item_mapping.get(item.name, "") - rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] - for rule_id in rule_ids: - if player_results.get(rule_id, None) is False: - del player_results[rule_id] - - return changed - - @override - def remove(self, state: CollectionState, item: Item) -> bool: - changed = super().remove(state, item) - if not changed or not self.rule_caching_enabled: - return changed - - player_results = state.rule_cache[self.player] - if self.rule_item_dependencies: - mapped_name = self.item_mapping.get(item.name, "") - rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] - for rule_id in rule_ids: - player_results.pop(rule_id, None) - - # clear all region dependent caches as none can be trusted - if self.rule_region_dependencies: - for rule_ids in self.rule_region_dependencies.values(): - for rule_id in rule_ids: - player_results.pop(rule_id, None) - - # clear all location dependent caches as they may have lost region access - if self.rule_location_dependencies: - for rule_ids in self.rule_location_dependencies.values(): - for rule_id in rule_ids: - player_results.pop(rule_id, None) - - # clear all entrance dependent caches as they may have lost region access - if self.rule_entrance_dependencies: - for rule_ids in self.rule_entrance_dependencies.values(): - for rule_id in rule_ids: - player_results.pop(rule_id, None) - - return changed - - @override - def reached_region(self, state: CollectionState, region: Region) -> None: - super().reached_region(state, region) - if self.rule_caching_enabled and self.rule_region_dependencies: - player_results = state.rule_cache[self.player] - for rule_id in self.rule_region_dependencies[region.name]: - player_results.pop(rule_id, None) - - -Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] - -OPERATORS: dict[Operator, Callable[..., bool]] = { - "eq": operator.eq, - "ne": operator.ne, - "gt": operator.gt, - "lt": operator.lt, - "ge": operator.ge, - "le": operator.le, - "contains": operator.contains, -} -operator_strings: dict[Operator, str] = { - "eq": "==", - "ne": "!=", - "gt": ">", - "lt": "<", - "ge": ">=", - "le": "<=", -} - -T = TypeVar("T") - - -@dataclasses.dataclass(frozen=True) -class OptionFilter(Generic[T]): - option: type[Option[T]] - value: T - operator: Operator = "eq" - - def to_dict(self) -> dict[str, Any]: - """Returns a JSON compatible dict representation of this option filter""" - return { - "option": f"{self.option.__module__}.{self.option.__name__}", - "value": self.value, - "operator": self.operator, - } - - def check(self, options: CommonOptions) -> bool: - """Tests the given options dataclass to see if it passes this option filter""" - option_name = next( - (name for name, cls in options.__class__.type_hints.items() if cls is self.option), - None, - ) - if option_name is None: - raise ValueError(f"Cannot find option {self.option.__name__} in options class {options.__class__.__name__}") - opt = cast(Option[Any] | None, getattr(options, option_name, None)) - if opt is None: - raise ValueError(f"Invalid option: {option_name}") - - return OPERATORS[self.operator](opt.value, self.value) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> Self: - """Returns a new OptionFilter instance from a dict representation""" - if "option" not in data or "value" not in data: - raise ValueError("Missing required value and/or option") - - option_path = data["option"] - try: - option_mod_name, option_cls_name = option_path.rsplit(".", 1) - option_module = importlib.import_module(option_mod_name) - option = getattr(option_module, option_cls_name, None) - except (ValueError, ImportError) as e: - raise ValueError(f"Cannot parse option '{option_path}'") from e - if option is None or not issubclass(option, Option): - raise ValueError(f"Invalid option '{option_path}' returns type '{option}' instead of Option subclass") - - value = data["value"] - operator = data.get("operator", "eq") - return cls(option=cast(type[Option[Any]], option), value=value, operator=operator) - - @classmethod - def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple["OptionFilter[Any]", ...]: - """Returns a tuple of OptionFilters instances from an iterable of dict representations""" - return tuple(cls.from_dict(o) for o in data) - - @override - def __str__(self) -> str: - op = operator_strings.get(self.operator, self.operator) - return f"{self.option.__name__} {op} {self.value}" + TWorld = TypeVar("TWorld") def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> Callable[..., int]: @@ -375,7 +164,7 @@ def __init_subclass__(cls, /, game: str) -> None: if cls.__qualname__ in custom_rules: raise TypeError(f"Rule {cls.__qualname__} has already been registered for game {game}") custom_rules[cls.__qualname__] = cls - elif cls.__module__ != "rule_builder": + elif cls.__module__ != "rule_builder.rules": # TODO: test to make sure this works on frozen raise TypeError("You cannot define custom rules for the base Archipelago world") cls.game_name = game diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 538a5d779c70..213be382a2a0 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -7,9 +7,10 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from NetUtils import JSONMessagePart from Options import Choice, PerGameCommonOptions, Toggle -from rule_builder import ( +from rule_builder.cached_world import CachedRuleBuilderWorld +from rule_builder.options import OptionFilter +from rule_builder.rules import ( And, - CachedRuleBuilderMixin, CanReachEntrance, CanReachLocation, CanReachRegion, @@ -24,7 +25,6 @@ HasFromListUnique, HasGroup, HasGroupUnique, - OptionFilter, Or, Rule, True_, @@ -32,7 +32,7 @@ from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package -from worlds.AutoWorld import WebWorld, World +from worlds.AutoWorld import WebWorld class ToggleOption(Toggle): @@ -68,7 +68,7 @@ class RuleBuilderWebWorld(WebWorld): tutorials = [] # noqa: RUF012 -class RuleBuilderWorld(CachedRuleBuilderMixin, World): # pyright: ignore[reportUnsafeMultipleInheritance] +class RuleBuilderWorld(CachedRuleBuilderWorld): game = GAME web = RuleBuilderWebWorld() item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 02fdb50426a0..376215cfdd7e 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -12,7 +12,7 @@ from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions from BaseClasses import CollectionState, Entrance -from rule_builder import CustomRuleRegister, Rule +from rule_builder.rules import CustomRuleRegister, Rule from Utils import Version if TYPE_CHECKING: From 9b1062f7bdf187cef8f93af688df5290e0e76961 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Thu, 25 Dec 2025 01:56:53 -0500 Subject: [PATCH 107/135] update docs --- .github/pyright-config.json | 4 +++- docs/rule builder.md | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/pyright-config.json b/.github/pyright-config.json index 85df9de501e2..fba044da0652 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -2,7 +2,9 @@ "include": [ "../BizHawkClient.py", "../Patch.py", - "../rule_builder.py", + "../rule_builder/cached_world.py", + "../rule_builder/options.py", + "../rule_builder/rules.py", "../test/param.py", "../test/general/test_groups.py", "../test/general/test_helpers.py", diff --git a/docs/rule builder.md b/docs/rule builder.md index 211d0d1cf1f6..59fe547e05ea 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -12,9 +12,9 @@ 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.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 blow, 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.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 mixin class `CachedRuleBuilderMixin`, which is a class your world can inherit. It adds a number of helper functions related to assigning and resolving rules. +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 blow, 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. ## Usage @@ -131,10 +131,10 @@ common_rule_only_on_easy = common_rule << easy_filter ### Enabling caching -The rule builder provides a `CachedRuleBuilderMixin` 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(CachedRuleBuilderMixin, World): +class MyWorld(CachedRuleBuilderWorld): game = "My Game" ``` @@ -149,7 +149,7 @@ If you have multiple real items that map to a single logic item, add a `item_map For example, if you have multiple `Currency x` 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(CachedRuleBuilderMixin, World): +class MyWorld(CachedRuleBuilderWorld): item_mapping = { "Currency x10": "Currency", "Currency x50": "Currency", @@ -369,7 +369,7 @@ If your logic has been done in custom JSON first, you can define a `from_dict` c ```python class BasicLogicRule(Rule, game="My Game"): @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[CachedRuleBuilderMixin]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: items = data.get("items", ()) return cls(*items) ``` From 01a1b253cd6a018432034017ec446ea2da582a32 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 27 Dec 2025 19:12:29 -0500 Subject: [PATCH 108/135] do not dupe collectionrule --- BaseClasses.py | 16 ++++++++-------- worlds/generic/Rules.py | 30 ++++++++++++------------------ 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index baaafb2fc50f..a0c00447845c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -85,7 +85,7 @@ class MultiWorld(): local_items: Dict[int, Options.LocalItems] non_local_items: Dict[int, Options.NonLocalItems] progression_balancing: Dict[int, Options.ProgressionBalancing] - completion_condition: Dict[int, AccessRule] + completion_condition: Dict[int, CollectionRule] indirect_connections: Dict[Region, Set[Entrance]] exclude_locations: Dict[int, Options.ExcludeLocations] priority_locations: Dict[int, Options.PriorityLocations] @@ -1175,8 +1175,8 @@ def set_item(self, item: str, player: int, count: int) -> None: self.prog_items[player][item] = count -AccessRule = Callable[[CollectionState], bool] -DEFAULT_ACCESS_RULE: AccessRule = staticmethod(lambda state: True) +CollectionRule = Callable[[CollectionState], bool] +DEFAULT_COLLECTION_RULE: CollectionRule = staticmethod(lambda state: True) class EntranceType(IntEnum): @@ -1185,7 +1185,7 @@ class EntranceType(IntEnum): class Entrance: - access_rule: AccessRule = DEFAULT_ACCESS_RULE + access_rule: CollectionRule = DEFAULT_COLLECTION_RULE hide_path: bool = False player: int name: str @@ -1372,7 +1372,7 @@ def add_event( self, location_name: str, item_name: str | None = None, - rule: AccessRule | None = None, + rule: CollectionRule | None = None, location_type: type[Location] | None = None, item_type: type[Item] | None = None, show_in_spoiler: bool = True, @@ -1411,7 +1411,7 @@ def add_event( return event_item def connect(self, connecting_region: Region, name: Optional[str] = None, - rule: Optional[AccessRule] = None) -> Entrance: + rule: Optional[CollectionRule] = None) -> Entrance: """ Connects this Region to another Region, placing the provided rule on the connection. @@ -1445,7 +1445,7 @@ def create_er_target(self, name: str) -> Entrance: return entrance def add_exits(self, exits: Iterable[str] | Mapping[str, str | None], - rules: Mapping[str, AccessRule] | None = None) -> List[Entrance]: + rules: Mapping[str, CollectionRule] | None = None) -> List[Entrance]: """ Connects current region to regions in exit dictionary. Passed region names must exist first. @@ -1484,7 +1484,7 @@ class Location: show_in_spoiler: bool = True progress_type: LocationProgressType = LocationProgressType.DEFAULT always_allow: Callable[[CollectionState, Item], bool] = staticmethod(lambda state, item: False) - access_rule: AccessRule = DEFAULT_ACCESS_RULE + access_rule: CollectionRule = DEFAULT_COLLECTION_RULE item_rule: Callable[[Item], bool] = staticmethod(lambda item: True) item: Optional[Item] = None diff --git a/worlds/generic/Rules.py b/worlds/generic/Rules.py index 31d725bff722..bfb79bbc2951 100644 --- a/worlds/generic/Rules.py +++ b/worlds/generic/Rules.py @@ -2,16 +2,10 @@ 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: From 773fce0a9e20773d76acd2b545b7ffd858a77ba9 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 27 Dec 2025 19:24:35 -0500 Subject: [PATCH 109/135] docs review feedback --- docs/rule builder.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 59fe547e05ea..bf35f0c372e8 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -77,9 +77,9 @@ 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. If you want a comparison that isn't equals, you can specify with the `operator` argument. +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`. -The following operators are allowed: +If you want a comparison that isn't equals, you can specify with the `operator` argument. The following operators are allowed: - `eq`: `==` - `ne`: `!=` @@ -201,7 +201,7 @@ All of the default `Has*` rules define this function already. ### Region dependencies -If your custom rule references other regions, it must define an `region_dependencies` function that returns a mapping of region names to the id of your rule. These will be combined to inform the caching system and indirect connections will be registered when you set this rule on an entrance. +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. These will be combined to inform the caching system and indirect connections will be registered when you set this rule on an entrance. ```python @dataclasses.dataclass() From 6ab758a48e456ecaa606ebb71f66a54598bb7948 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 27 Dec 2025 19:43:10 -0500 Subject: [PATCH 110/135] missed a file --- worlds/AutoWorld.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 376215cfdd7e..c366dd780f66 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -16,7 +16,7 @@ from Utils import Version if TYPE_CHECKING: - from BaseClasses import AccessRule, MultiWorld, Item, Location, Tutorial, Region + from BaseClasses import CollectionRule, Item, Location, MultiWorld, Region, Tutorial from NetUtils import GamesPackage, MultiData from settings import Group @@ -610,7 +610,7 @@ def rule_from_dict(cls, data: Mapping[str, Any]) -> Rule[Self]: rule_class = cls.get_rule_cls(name) return rule_class.from_dict(data, cls) - def set_rule(self, spot: Location | Entrance, rule: AccessRule | Rule[Any]) -> None: + 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) @@ -619,7 +619,7 @@ def set_rule(self, spot: Location | Entrance, rule: AccessRule | Rule[Any]) -> N self._register_rule_indirects(rule, spot) spot.access_rule = rule - def set_completion_rule(self, rule: AccessRule | Rule[Any]) -> None: + 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) @@ -630,7 +630,7 @@ def create_entrance( self, from_region: Region, to_region: Region, - rule: AccessRule | Rule[Any] | None = None, + rule: CollectionRule | Rule[Any] | None = None, name: str | None = None, force_creation: bool = False, ) -> Entrance | None: From ed38f488380a2e3c27294d9f08c270e5849c6763 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 29 Dec 2025 23:05:35 -0500 Subject: [PATCH 111/135] remove rule_caching_enabled from base World --- docs/rule builder.md | 16 ++++--- rule_builder/cached_world.py | 13 ++---- rule_builder/rules.py | 58 +++++++++++++++++-------- test/general/test_rule_builder.py | 72 ++++++++++++++++++++++--------- worlds/AutoWorld.py | 3 -- 5 files changed, 105 insertions(+), 57 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index bf35f0c372e8..381267a4a71b 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -170,7 +170,8 @@ To add a rule that checks if the user has enough mcguffins to goal, with a rando @dataclasses.dataclass() class CanGoal(Rule["MyWorld"], game="My Game"): def _instantiate(self, world: "MyWorld") -> Rule.Resolved: - return self.Resolved(world.required_mcguffins, player=world.player, caching_enabled=world.rule_caching_enabled) + # 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 @@ -259,7 +260,7 @@ By default your custom rule will work through the cache system as any other rule ### Caveats -- Ensure you are passing `caching_enabled=world.rule_caching_enabled` in your `_instantiate` function when creating resolved rule instances if your world has caching enabled. +- 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 so they get registered with the world's caching system. @@ -421,11 +422,6 @@ This section is provided for reference, refer to the above sections for examples These are properties and helpers that are available to you in your world. -#### Properties - -- `item_mapping: dict[str, str]`: A mapping of actual item name to logical item name -- `rule_caching_enabled: bool`: A boolean value to enable or disable rule caching for this world - #### Methods - `rule_from_dict(data)`: Create a rule instance from a deserialized dict representation @@ -434,6 +430,12 @@ These are properties and helpers that are available to you in your world. - `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. diff --git a/rule_builder/cached_world.py b/rule_builder/cached_world.py index 28fae7b44048..81e6bf07d40c 100644 --- a/rule_builder/cached_world.py +++ b/rule_builder/cached_world.py @@ -30,7 +30,7 @@ class CachedRuleBuilderWorld(World): item = Item("Currency x500"), rule = Has("Currency", count=1000), item_mapping = {"Currency x500": "Currency"}""" rule_caching_enabled: ClassVar[bool] = True - """Enable or disable the rule result caching system""" + """Flag to inform rules that the caching system for this world is enabled. It should not be overridden.""" def __init__(self, multiworld: MultiWorld, player: int) -> None: super().__init__(multiworld, player) @@ -41,8 +41,6 @@ def __init__(self, multiworld: MultiWorld, player: int) -> None: @override def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None: - if not self.rule_caching_enabled: - return for item_name, rule_ids in resolved_rule.item_dependencies().items(): self.rule_item_dependencies[item_name] |= rule_ids for region_name, rule_ids in resolved_rule.region_dependencies().items(): @@ -54,9 +52,6 @@ def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None: def register_dependencies(self) -> None: """Register all rules that depend on locations or entrances with their dependencies""" - if not self.rule_caching_enabled: - return - for location_name, rule_ids in self.rule_location_dependencies.items(): try: location = self.get_location(location_name) @@ -84,7 +79,7 @@ def register_dependencies(self) -> None: @override def collect(self, state: CollectionState, item: Item) -> bool: changed = super().collect(state, item) - if changed and self.rule_caching_enabled and self.rule_item_dependencies: + if changed and self.rule_item_dependencies: player_results = state.rule_cache[self.player] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] @@ -97,7 +92,7 @@ def collect(self, state: CollectionState, item: Item) -> bool: @override def remove(self, state: CollectionState, item: Item) -> bool: changed = super().remove(state, item) - if not changed or not self.rule_caching_enabled: + if not changed: return changed player_results = state.rule_cache[self.player] @@ -130,7 +125,7 @@ def remove(self, state: CollectionState, item: Item) -> bool: @override def reached_region(self, state: CollectionState, region: Region) -> None: super().reached_region(state, region) - if self.rule_caching_enabled and self.rule_region_dependencies: + if self.rule_region_dependencies: player_results = state.rule_cache[self.player] for rule_id in self.rule_region_dependencies[region.name]: player_results.pop(rule_id, None) diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 21eab8468b7c..569bc1b0c61e 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -96,7 +96,7 @@ def __post_init__(self) -> None: def _instantiate(self, world: TWorld) -> "Resolved": """Create a new resolved rule for this world""" - return self.Resolved(player=world.player, caching_enabled=world.rule_caching_enabled) + return self.Resolved(player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False)) def resolve(self, world: TWorld) -> "Resolved": """Resolve a rule with the given world""" @@ -305,7 +305,11 @@ def __init__(self, *children: Rule[TWorld], options: Iterable[OptionFilter[Any]] @override def _instantiate(self, world: TWorld) -> Rule.Resolved: children = [c.resolve(world) for c in self.children] - return self.Resolved(tuple(children), player=world.player, caching_enabled=world.rule_caching_enabled) + return self.Resolved( + tuple(children), + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) @override def to_dict(self) -> dict[str, Any]: @@ -435,7 +439,11 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(clauses) == 1: return clauses[0] - return And.Resolved(tuple(clauses), player=world.player, caching_enabled=world.rule_caching_enabled) + return And.Resolved( + tuple(clauses), + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) class Resolved(NestedRule.Resolved): @override @@ -515,7 +523,11 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(clauses) == 1: return clauses[0] - return Or.Resolved(tuple(clauses), player=world.player, caching_enabled=world.rule_caching_enabled) + return Or.Resolved( + tuple(clauses), + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) class Resolved(NestedRule.Resolved): @override @@ -558,7 +570,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( self.child.resolve(world), player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -661,7 +673,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: self.item_name, self.count, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -730,7 +742,11 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return True_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) - return self.Resolved(self.item_names, player=world.player, caching_enabled=world.rule_caching_enabled) + return self.Resolved( + self.item_names, + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) @override @classmethod @@ -839,7 +855,11 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return False_().resolve(world) if len(self.item_names) == 1: return Has(self.item_names[0]).resolve(world) - return self.Resolved(self.item_names, player=world.player, caching_enabled=world.rule_caching_enabled) + return self.Resolved( + self.item_names, + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) @override @classmethod @@ -948,7 +968,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( tuple(self.item_counts.items()), player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1053,7 +1073,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( tuple(self.item_counts.items()), player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1166,7 +1186,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: self.item_names, self.count, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1294,7 +1314,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: self.item_names, self.count, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1410,7 +1430,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: item_names, self.count, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1484,7 +1504,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: item_names, self.count, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1566,7 +1586,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: self.location_name, parent_region_name, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @@ -1626,7 +1646,11 @@ class CanReachRegion(Rule[TWorld], game="Archipelago"): @override def _instantiate(self, world: TWorld) -> Rule.Resolved: - return self.Resolved(self.region_name, player=world.player, caching_enabled=world.rule_caching_enabled) + return self.Resolved( + self.region_name, + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) @override def __str__(self) -> str: @@ -1691,7 +1715,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: self.entrance_name, parent_region_name, player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 213be382a2a0..f073bde0657e 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -32,7 +32,7 @@ from test.general import setup_solo_multiworld from test.param import classvar_matrix from worlds import network_data_package -from worlds.AutoWorld import WebWorld +from worlds.AutoWorld import WebWorld, World class ToggleOption(Toggle): @@ -52,24 +52,57 @@ class RuleBuilderOptions(PerGameCommonOptions): choice_option: ChoiceOption -GAME = "Rule Builder Test Game" +GAME_NAME = "Rule Builder Test Game" +GAME_NAME_CACHED = "Rule Builder Cached Test Game" LOC_COUNT = 20 +class RuleBuilderWebWorld(WebWorld): + tutorials = [] # noqa: RUF012 + + class RuleBuilderItem(Item): - game = GAME + game = GAME_NAME class RuleBuilderLocation(Location): - game = GAME + game = GAME_NAME -class RuleBuilderWebWorld(WebWorld): - tutorials = [] # noqa: RUF012 +class RuleBuilderWorld(World): + game = GAME_NAME + web = RuleBuilderWebWorld() + item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} + location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} + item_name_groups: ClassVar[dict[str, set[str]]] = { + "Group 1": {"Item 1", "Item 2", "Item 3"}, + "Group 2": {"Item 4", "Item 5"}, + } + hidden = True + options_dataclass = RuleBuilderOptions + options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] + origin_region_name = "Region 1" + + @override + def create_item(self, name: str) -> RuleBuilderItem: + classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression + return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) + + @override + def get_filler_item_name(self) -> str: + return "Filler" + + +class RuleBuilderCachedItem(Item): + game = GAME_NAME_CACHED + + +class RuleBuilderCachedLocation(Location): + game = GAME_NAME_CACHED -class RuleBuilderWorld(CachedRuleBuilderWorld): - game = GAME +class RuleBuilderCachedWorld(CachedRuleBuilderWorld): + game = GAME_NAME_CACHED web = RuleBuilderWebWorld() item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} @@ -83,9 +116,9 @@ class RuleBuilderWorld(CachedRuleBuilderWorld): origin_region_name = "Region 1" @override - def create_item(self, name: str) -> "RuleBuilderItem": + def create_item(self, name: str) -> RuleBuilderCachedItem: classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression - return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) + return RuleBuilderCachedItem(name, classification, self.item_name_to_id[name], self.player) @override def get_filler_item_name(self) -> str: @@ -93,6 +126,7 @@ def get_filler_item_name(self) -> str: network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() +network_data_package["games"][RuleBuilderCachedWorld.game] = RuleBuilderCachedWorld.get_data_package_data() @classvar_matrix( @@ -184,13 +218,12 @@ def get_filler_item_name(self) -> str: ) ) class TestSimplify(unittest.TestCase): - rules: ClassVar[tuple[Rule[RuleBuilderWorld], Rule.Resolved]] + rules: ClassVar[tuple[Rule[Any], Rule.Resolved]] def test_simplify(self) -> None: multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) world = multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) - world.rule_caching_enabled = False # pyright: ignore[reportAttributeAccessIssue] rule, expected = self.rules resolved_rule = rule.resolve(world) self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") @@ -314,15 +347,15 @@ def test_has_all_hash(self) -> None: class TestCaching(unittest.TestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: RuleBuilderCachedWorld # pyright: ignore[reportUninitializedInstanceVariable] state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override def setUp(self) -> None: - self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + self.multiworld = setup_solo_multiworld(RuleBuilderCachedWorld, seed=0) world = self.multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) + assert isinstance(world, RuleBuilderCachedWorld) self.world = world self.state = self.multiworld.state @@ -331,9 +364,9 @@ def setUp(self) -> None: region3 = Region("Region 3", self.player, self.multiworld) self.multiworld.regions.extend([region1, region2, region3]) - region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderLocation) - region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) - region3.add_locations({"Location 5": 5}, RuleBuilderLocation) + region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderCachedLocation) + region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderCachedLocation) + region3.add_locations({"Location 5": 5}, RuleBuilderCachedLocation) world.create_entrance(region1, region2, Has("Item 1")) world.create_entrance(region1, region3, HasAny("Item 3", "Item 4")) @@ -416,7 +449,6 @@ def setUp(self) -> None: self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) world = self.multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) - world.rule_caching_enabled = False # pyright: ignore[reportAttributeAccessIssue] self.world = world self.state = self.multiworld.state @@ -439,8 +471,6 @@ def setUp(self) -> None: for i in range(1, LOC_COUNT + 1): self.multiworld.itempool.append(world.create_item(f"Item {i}")) - world.register_dependencies() - return super().setUp() def test_item_logic(self) -> None: diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index c366dd780f66..56fdd9dce094 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -349,9 +349,6 @@ class World(metaclass=AutoWorldRegister): world_version: ClassVar[Version] = Version(0, 0, 0) """Optional world version loaded from archipelago.json""" - rule_caching_enabled: ClassVar[bool] = False - """Enable or disable the rule result caching system""" - def __init__(self, multiworld: "MultiWorld", player: int): assert multiworld is not None self.multiworld = multiworld From f10415abc77694aed49f1352d04a1ce2b16a0081 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 29 Dec 2025 23:26:37 -0500 Subject: [PATCH 112/135] update docs on caching --- docs/rule builder.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 381267a4a71b..99900463be4e 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -160,9 +160,9 @@ 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 to 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`. You may need to also 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: @@ -180,12 +180,28 @@ class CanGoal(Rule["MyWorld"], game="My Game"): return state.has("McGuffin", self.player, count=self.goal) def item_dependencies(self) -> dict[str, set[int]]: + # this function is only required if you have caching enabled return {"McGuffin": {id(self)}} + + def explain_json(self) +``` + +Your custom rule can also resolve to builtin rules instead of needing to define your own resolved rule: + +```python +@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 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. +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() @@ -202,7 +218,7 @@ 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. These will be combined to inform the caching system and indirect connections will be registered when you set this rule on an entrance. +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() @@ -219,7 +235,7 @@ 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. These dependencies will be combined to inform the caching system. +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() @@ -236,7 +252,7 @@ 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. These dependencies will be combined to inform the caching system. +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() @@ -262,7 +278,7 @@ By default your custom rule will work through the cache system as any other rule - 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 so they get registered with the world's caching system. +- If your rule creates child rules ensure they are being resolved through the world rather than creating `Resolved` instances directly. ## Serialization From 0498ea6c49a288f76169bea5d5819441a906c535 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 29 Dec 2025 23:47:34 -0500 Subject: [PATCH 113/135] shuffle around some docs --- docs/rule builder.md | 112 +++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 99900463be4e..b20a23eba104 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -5,14 +5,14 @@ This document describes the API provided for the rule builder. Using this API pr - Rule classes that avoid all the common pitfalls - Logic optimization - Automatic result caching (opt-in) -- Serialize/deserialize to JSON +- 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 blow, 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. 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. @@ -129,7 +129,7 @@ easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)] common_rule_only_on_easy = common_rule << easy_filter ``` -### Enabling caching +## Enabling caching The rule builder provides a `CachedRuleBuilderWorld` base class for your `World` class that enables caching on your rules. @@ -138,11 +138,11 @@ 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 disabled 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. -If you enabled caching and your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. +If you enable caching and your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. -## Item name mapping +### 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. @@ -169,6 +169,7 @@ To add a rule that checks if the user has enough mcguffins to goal, with a rando ```python @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) @@ -176,17 +177,26 @@ class CanGoal(Rule["MyWorld"], game="My Game"): 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)}} - def explain_json(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 resolved rule: +Your custom rule can also resolve to builtin rules instead of needing to define your own: ```python @dataclasses.dataclass() @@ -201,7 +211,7 @@ 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() @@ -218,7 +228,7 @@ 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() @@ -235,7 +245,7 @@ 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() @@ -252,7 +262,7 @@ 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() @@ -267,6 +277,45 @@ class MyRule(Rule["MyWorld"], game="My Game"): 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: + +```python +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. @@ -391,45 +440,6 @@ class BasicLogicRule(Rule, game="My Game"): return cls(*items) ``` -## Rule explanations - -Resolved rules have a default implementation for `explain_json` and `explain_str` functions. The former 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 returns similar information but as a string. It is useful when debugging. - -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"): - 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" -``` - ## APIs This section is provided for reference, refer to the above sections for examples. From 5275bfd702eebd78553d667707211c21349cc7e7 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 30 Dec 2025 18:26:42 -0500 Subject: [PATCH 114/135] fix merge --- worlds/astalon/bases.py | 22 ++++----- worlds/astalon/logic/custom_rules.py | 69 ++++++++++++++------------- worlds/astalon/logic/main_campaign.py | 3 +- worlds/astalon/test/test_rules.py | 3 +- worlds/astalon/tracker.py | 5 +- 5 files changed, 49 insertions(+), 53 deletions(-) diff --git a/worlds/astalon/bases.py b/worlds/astalon/bases.py index bd7c50f213d0..086b3439e155 100644 --- a/worlds/astalon/bases.py +++ b/worlds/astalon/bases.py @@ -1,27 +1,21 @@ -from abc import ABCMeta -from typing import Any, ClassVar +from typing import ClassVar -from rule_builder import RuleWorldMixin -from worlds.AutoWorld import AutoWorldRegister, World +from BaseClasses import MultiWorld +from rule_builder.cached_world import CachedRuleBuilderWorld -from .constants import GAME_NAME from .items import Character from .options import AstalonOptions from .settings import AstalonSettings -class AstalonWorldMetaclass(AutoWorldRegister, ABCMeta): - def __new__(mcs, name: str, bases: tuple[type, ...], dct: dict[str, Any]) -> AutoWorldRegister: - if name == "AstalonWorld": - return super().__new__(mcs, name, bases, dct) - return super(AutoWorldRegister, mcs).__new__(mcs, name, bases, dct) - - -class AstalonWorldBase(RuleWorldMixin, World, metaclass=AstalonWorldMetaclass): # pyright: ignore[reportUnsafeMultipleInheritance] - game = GAME_NAME +class AstalonWorldBase(CachedRuleBuilderWorld): options_dataclass = AstalonOptions settings: ClassVar[AstalonSettings] # pyright: ignore[reportIncompatibleVariableOverride] options: AstalonOptions # pyright: ignore[reportIncompatibleVariableOverride] starting_characters: list[Character] extra_gold_eyes: int = 0 + + def __init__(self, multiworld: MultiWorld, player: int) -> None: + super().__init__(multiworld, player) + self.starting_characters = [] diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index ab821aec7276..8a88892a65aa 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -5,10 +5,11 @@ from typing_extensions import override -import rule_builder from BaseClasses import CollectionState from NetUtils import JSONMessagePart from Options import Option +from rule_builder import rules +from rule_builder.options import OptionFilter from ..bases import AstalonWorldBase from ..constants import GAME_NAME @@ -45,25 +46,25 @@ def as_str(value: Enum | str | None) -> str: @dataclasses.dataclass(init=False) -class Has(rule_builder.Has[AstalonWorldBase], game=GAME_NAME): +class Has(rules.Has[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, item_name: ItemName | Events, count: int = 1, *, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(as_str(item_name), count, options=options) @dataclasses.dataclass(init=False) -class HasAll(rule_builder.HasAll[AstalonWorldBase], game=GAME_NAME): +class HasAll(rules.HasAll[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, *item_names: ItemName | Events, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: names = [as_str(name) for name in item_names] if len(names) != len(set(names)): @@ -73,12 +74,12 @@ def __init__( @dataclasses.dataclass(init=False) -class HasAny(rule_builder.HasAny[AstalonWorldBase], game=GAME_NAME): +class HasAny(rules.HasAny[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, *item_names: ItemName | Events, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: names = [as_str(name) for name in item_names] if len(names) != len(set(names)): @@ -88,7 +89,7 @@ def __init__( @dataclasses.dataclass(init=False) -class CanReachLocation(rule_builder.CanReachLocation[AstalonWorldBase], game=GAME_NAME): +class CanReachLocation(rules.CanReachLocation[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, @@ -96,32 +97,32 @@ def __init__( parent_region_name: RegionName | None = None, skip_indirect_connection: bool = False, *, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(as_str(location_name), as_str(parent_region_name), skip_indirect_connection, options=options) @dataclasses.dataclass(init=False) -class CanReachRegion(rule_builder.CanReachRegion[AstalonWorldBase], game=GAME_NAME): +class CanReachRegion(rules.CanReachRegion[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, region_name: RegionName, *, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(as_str(region_name), options=options) @dataclasses.dataclass(init=False) -class CanReachEntrance(rule_builder.CanReachEntrance[AstalonWorldBase], game=GAME_NAME): +class CanReachEntrance(rules.CanReachEntrance[AstalonWorldBase], game=GAME_NAME): @override def __init__( self, from_region: RegionName, to_region: RegionName, *, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: entrance_name = f"{as_str(from_region)} -> {as_str(to_region)}" super().__init__(entrance_name, as_str(from_region), options=options) @@ -133,17 +134,17 @@ class ToggleRule(HasAll, game=GAME_NAME): otherwise: bool = False @override - def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: + def _instantiate(self, world: AstalonWorldBase) -> rules.Rule.Resolved: items = tuple(cast(ItemName | Events, item) for item in self.item_names) if len(items) == 1: - rule = Has(items[0], options=[rule_builder.OptionFilter(self.option_cls, 1)]) + rule = Has(items[0], options=[OptionFilter(self.option_cls, 1)]) else: - rule = HasAll(*items, options=[rule_builder.OptionFilter(self.option_cls, 1)]) + rule = HasAll(*items, options=[OptionFilter(self.option_cls, 1)]) if self.otherwise: - return rule_builder.Or( + return rules.Or( rule, - rule_builder.True_(options=[rule_builder.OptionFilter(self.option_cls, 0)]), + rules.True_(options=[OptionFilter(self.option_cls, 0)]), ).resolve(world) return rule.resolve(world) @@ -157,7 +158,7 @@ def __init__( self, *doors: WhiteDoor, otherwise: bool = False, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -171,7 +172,7 @@ def __init__( self, *doors: BlueDoor, otherwise: bool = False, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -185,7 +186,7 @@ def __init__( self, *doors: RedDoor, otherwise: bool = False, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -199,7 +200,7 @@ def __init__( self, *switches: Switch | Crystal | Face, otherwise: bool = False, - options: Iterable[rule_builder.OptionFilter[Any]] = (), + options: Iterable[OptionFilter[Any]] = (), ) -> None: super().__init__(*switches, options=options) self.otherwise: bool = otherwise @@ -207,20 +208,20 @@ def __init__( @dataclasses.dataclass(init=False) class HasElevator(HasAll, game=GAME_NAME): - def __init__(self, elevator: Elevator, *, options: Iterable[rule_builder.OptionFilter[Any]] = ()) -> None: + def __init__(self, elevator: Elevator, *, options: Iterable[OptionFilter[Any]] = ()) -> None: super().__init__( KeyItem.ASCENDANT_KEY, elevator, - options=[*options, rule_builder.OptionFilter(RandomizeElevator, RandomizeElevator.option_true)], + options=[*options, OptionFilter(RandomizeElevator, RandomizeElevator.option_true)], ) @dataclasses.dataclass() -class HasGoal(rule_builder.Rule[AstalonWorldBase], game=GAME_NAME): +class HasGoal(rules.Rule[AstalonWorldBase], game=GAME_NAME): @override - def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: + def _instantiate(self, world: AstalonWorldBase) -> rules.Rule.Resolved: if world.options.goal.value != Goal.option_eye_hunt: - return rule_builder.True_().resolve(world) + return rules.True_().resolve(world) return Has.Resolved( Eye.GOLD.value, count=world.options.additional_eyes_required.value, @@ -230,20 +231,20 @@ def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: @dataclasses.dataclass() -class HardLogic(rule_builder.WrapperRule[AstalonWorldBase], game=GAME_NAME): +class HardLogic(rules.WrapperRule[AstalonWorldBase], game=GAME_NAME): @override - def _instantiate(self, world: AstalonWorldBase) -> rule_builder.Rule.Resolved: + def _instantiate(self, world: AstalonWorldBase) -> rules.Rule.Resolved: if world.options.difficulty.value == Difficulty.option_hard: return self.child.resolve(world) if getattr(world.multiworld, "generation_is_fake", False): return self.Resolved( - world.get_cached_rule(self.child.resolve(world)), + self.child.resolve(world), player=world.player, caching_enabled=world.rule_caching_enabled, ) - return rule_builder.False_().resolve(world) + return rules.False_().resolve(world) - class Resolved(rule_builder.WrapperRule.Resolved): + class Resolved(rules.WrapperRule.Resolved): @override def _evaluate(self, state: CollectionState) -> bool: return state.has(Events.FAKE_OOL_ITEM.value, self.player) and self.child(state) @@ -265,8 +266,8 @@ def explain_json(self, state: CollectionState | None = None) -> list[JSONMessage @dataclasses.dataclass() -class CampfireWarp(rule_builder.True_, game=GAME_NAME): - class Resovled(rule_builder.True_.Resolved): +class CampfireWarp(rules.True_, game=GAME_NAME): + class Resolved(rules.True_.Resolved): @override def explain_json(self, state: CollectionState | None = None) -> list[JSONMessagePart]: return [{"type": "color", "color": "green", "text": "Campfire Warp"}] diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 7d44ed34181c..83455b144beb 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -1,4 +1,5 @@ -from rule_builder import Filtered, OptionFilter, Rule, True_ +from rule_builder.options import OptionFilter +from rule_builder.rules import Filtered, Rule, True_ from ..bases import AstalonWorldBase from ..items import ( diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py index d298935c62ec..1ee0d08256f0 100644 --- a/worlds/astalon/test/test_rules.py +++ b/worlds/astalon/test/test_rules.py @@ -1,6 +1,7 @@ from typing_extensions import override -from rule_builder import And, OptionFilter, Or, True_ +from rule_builder.options import OptionFilter +from rule_builder.rules import And, Or, True_ from ..items import BlueDoor, Crystal from ..logic.custom_rules import Has, HasAll, HasBlue, HasSwitch diff --git a/worlds/astalon/tracker.py b/worlds/astalon/tracker.py index 07c9bc5254bb..b31dfa12d13e 100644 --- a/worlds/astalon/tracker.py +++ b/worlds/astalon/tracker.py @@ -3,12 +3,11 @@ from typing_extensions import override -from BaseClasses import CollectionState, Entrance, Location, Region +from BaseClasses import CollectionRule, CollectionState, Entrance, Location, Region from NetUtils import JSONMessagePart from Options import Option -from rule_builder import Rule +from rule_builder.rules import Rule from Utils import get_intended_text # pyright: ignore[reportUnknownVariableType] -from worlds.generic.Rules import CollectionRule from .bases import AstalonWorldBase from .items import Character, Events From e3678bf43a1327d6afb93cd1348356f89c1b5fa5 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Tue, 30 Dec 2025 18:29:35 -0500 Subject: [PATCH 115/135] rm old test --- worlds/astalon/test/test_rules.py | 82 ------------------------------- worlds/astalon/world.py | 1 - 2 files changed, 83 deletions(-) delete mode 100644 worlds/astalon/test/test_rules.py diff --git a/worlds/astalon/test/test_rules.py b/worlds/astalon/test/test_rules.py deleted file mode 100644 index 1ee0d08256f0..000000000000 --- a/worlds/astalon/test/test_rules.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing_extensions import override - -from rule_builder.options import OptionFilter -from rule_builder.rules import And, Or, True_ - -from ..items import BlueDoor, Crystal -from ..logic.custom_rules import Has, HasAll, HasBlue, HasSwitch -from ..logic.main_campaign import has_arias, has_block, has_bram, has_star -from ..options import Difficulty, RandomizeCharacters -from .bases import AstalonTestBase - - -class RuleHashTest(AstalonTestBase): - auto_construct = False - - @property - @override - def run_default_tests(self) -> bool: - return False - - def test_same_rules_have_same_hash(self) -> None: - rule1 = Has.Resolved("Item", player=1) - rule2 = Has.Resolved("Item", player=1) - self.assertEqual(hash(rule1), hash(rule2)) - - def test_different_rules_have_different_hashes(self) -> None: - rule1 = Has.Resolved("Item", player=1) - rule2 = Has.Resolved("Item", player=2) - self.assertNotEqual(hash(rule1), hash(rule2)) - - rule3 = Has.Resolved("Item1", player=1) - rule4 = Has.Resolved("Item2", player=1) - self.assertNotEqual(hash(rule3), hash(rule4)) - - -class RuleResolutionTest(AstalonTestBase): - options = { # noqa: RUF012 - "difficulty": "easy", - "randomize_characters": "trio", - "randomize_key_items": "true", - "randomize_health_pickups": "true", - "randomize_attack_pickups": "true", - "randomize_white_keys": "true", - "randomize_blue_keys": "true", - "randomize_red_keys": "true", - "randomize_shop": "true", - "randomize_elevator": "true", - "randomize_switches": "true", - } - - @property - @override - def run_default_tests(self) -> bool: - return False - - def test_upper_path_rule_easy(self) -> None: - rule = Or( - HasSwitch(Crystal.GT_ROTA), - Or( - True_(options=[OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla)]), - Or( - has_arias | has_bram, - options=[OptionFilter(RandomizeCharacters, RandomizeCharacters.option_vanilla, operator="gt")], - ), - options=[OptionFilter(Difficulty, Difficulty.option_hard)], - ), - And(has_star, HasBlue(BlueDoor.GT_RING, otherwise=True)), - has_block, - ) - expected = Or.Resolved( - ( - HasAll.Resolved( - ("Blue Door (Gorgon Tomb - Ring of the Ancients)", "Bram", "Morning Star"), - player=self.player, - ), - HasAll.Resolved(("Magic Block", "Zeek"), player=self.player), - Has.Resolved("Crystal (Gorgon Tomb - RotA)", player=self.player), - ), - player=self.player, - ) - instance = rule.resolve(self.world) - self.assertEqual(instance, expected, f"\n{instance}\n{expected}") diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 56813569be71..2def483b2605 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -91,7 +91,6 @@ class AstalonWorld(AstalonUTWorld): location_name_groups = location_name_groups item_name_to_id = item_name_to_id location_name_to_id = location_name_to_id - rule_caching_enabled = True _character_strengths: ClassVar[dict[int, dict[str, float]] | None] = None From fc45650fc770ab2d9a49a8db3b40d37b987fd446 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 14:08:06 -0500 Subject: [PATCH 116/135] use option instead of option.value --- rule_builder/options.py | 2 +- test/general/test_rule_builder.py | 56 +++++++++++++++---------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/rule_builder/options.py b/rule_builder/options.py index 23aff25d0b60..deae7f9a0c9e 100644 --- a/rule_builder/options.py +++ b/rule_builder/options.py @@ -57,7 +57,7 @@ def check(self, options: CommonOptions) -> bool: if opt is None: raise ValueError(f"Invalid option: {option_name}") - return OPERATORS[self.operator](opt.value, self.value) + return OPERATORS[self.operator](opt, self.value) @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index f073bde0657e..bf751f37dadf 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -6,9 +6,9 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from NetUtils import JSONMessagePart -from Options import Choice, PerGameCommonOptions, Toggle +from Options import Choice, Option, PerGameCommonOptions, Toggle from rule_builder.cached_world import CachedRuleBuilderWorld -from rule_builder.options import OptionFilter +from rule_builder.options import Operator, OptionFilter from rule_builder.rules import ( And, CanReachEntrance, @@ -229,38 +229,36 @@ def test_simplify(self) -> None: self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") +@classvar_matrix( + cases=( + (ToggleOption, 0, 0, "eq", True), + (ToggleOption, 0, 1, "eq", False), + (ToggleOption, 0, 0, "ne", False), + (ToggleOption, 0, 1, "ne", True), + (ChoiceOption, 0, 1, "gt", False), + (ChoiceOption, 1, 1, "gt", False), + (ChoiceOption, 2, 1, "gt", True), + ) +) class TestOptions(unittest.TestCase): + cases: ClassVar[tuple[type[Option[Any]], Any, Any, Operator, bool]] multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] - @override - def setUp(self) -> None: - self.multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) - world = self.multiworld.worlds[1] + def test_simplify(self) -> None: + multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + world = multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) - self.world = world - return super().setUp() - - def test_option_filtering(self) -> None: - rule = Or(Has("A", options=[OptionFilter(ToggleOption, 0)]), Has("B", options=[OptionFilter(ToggleOption, 1)])) - - self.world.options.toggle_option.value = 0 - self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) - - self.world.options.toggle_option.value = 1 - self.assertEqual(rule.resolve(self.world), Has.Resolved("B", player=1)) - - def test_gt_filtering(self) -> None: - rule = Or(Has("A", options=[OptionFilter(ChoiceOption, 1, operator="gt")]), False_()) - - self.world.options.choice_option.value = 0 - self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) - - self.world.options.choice_option.value = 1 - self.assertEqual(rule.resolve(self.world), False_.Resolved(player=1)) - - self.world.options.choice_option.value = 2 - self.assertEqual(rule.resolve(self.world), Has.Resolved("A", player=1)) + option_cls, world_value, filter_value, operator, result = self.cases + expected = True_.Resolved(player=1) if result else False_.Resolved(player=1) + if option_cls is ToggleOption: + world.options.toggle_option.value = world_value + elif option_cls is ChoiceOption: + world.options.choice_option.value = world_value + + rule = True_(options=[OptionFilter(option_cls, filter_value, operator)]) + resolved_rule = rule.resolve(world) + self.assertEqual(resolved_rule, expected) @classvar_matrix( From 5fff1c59483893e2affd899096545e69dc0c004c Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 17:58:02 -0500 Subject: [PATCH 117/135] add in operator and more testing --- rule_builder/options.py | 27 +++++++------- rule_builder/rules.py | 14 +++---- test/general/test_rule_builder.py | 61 +++++++++++++++++++++---------- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/rule_builder/options.py b/rule_builder/options.py index deae7f9a0c9e..979a72315ec5 100644 --- a/rule_builder/options.py +++ b/rule_builder/options.py @@ -2,15 +2,15 @@ import importlib import operator from collections.abc import Callable, Iterable -from typing import Any, Generic, Literal, Self, cast +from typing import Any, Final, Literal, Self, cast -from typing_extensions import TypeVar, override +from typing_extensions import override from Options import CommonOptions, Option -Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains"] +Operator = Literal["eq", "ne", "gt", "lt", "ge", "le", "contains", "in"] -OPERATORS: dict[Operator, Callable[..., bool]] = { +OPERATORS: Final[dict[Operator, Callable[..., bool]]] = { "eq": operator.eq, "ne": operator.ne, "gt": operator.gt, @@ -18,8 +18,9 @@ "ge": operator.ge, "le": operator.le, "contains": operator.contains, + "in": operator.contains, } -operator_strings: dict[Operator, str] = { +OPERATOR_STRINGS: Final[dict[Operator, str]] = { "eq": "==", "ne": "!=", "gt": ">", @@ -27,14 +28,13 @@ "ge": ">=", "le": "<=", } - -T = TypeVar("T") +REVERSE_OPERATORS: Final[tuple[Operator, ...]] = ("in",) @dataclasses.dataclass(frozen=True) -class OptionFilter(Generic[T]): - option: type[Option[T]] - value: T +class OptionFilter: + option: type[Option[Any]] + value: Any operator: Operator = "eq" def to_dict(self) -> dict[str, Any]: @@ -57,7 +57,8 @@ def check(self, options: CommonOptions) -> bool: if opt is None: raise ValueError(f"Invalid option: {option_name}") - return OPERATORS[self.operator](opt, self.value) + fn = OPERATORS[self.operator] + return fn(self.value, opt) if self.operator in REVERSE_OPERATORS else fn(opt, self.value) @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: @@ -80,11 +81,11 @@ def from_dict(cls, data: dict[str, Any]) -> Self: return cls(option=cast(type[Option[Any]], option), value=value, operator=operator) @classmethod - def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple["OptionFilter[Any]", ...]: + def multiple_from_dict(cls, data: Iterable[dict[str, Any]]) -> tuple[Self, ...]: """Returns a tuple of OptionFilters instances from an iterable of dict representations""" return tuple(cls.from_dict(o) for o in data) @override def __str__(self) -> str: - op = operator_strings.get(self.operator, self.operator) + op = OPERATOR_STRINGS.get(self.operator, self.operator) return f"{self.option.__name__} {op} {self.value}" diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 569bc1b0c61e..0d417edbf000 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -84,7 +84,7 @@ def get_rule_cls(cls, game_name: str, rule_name: str) -> type["Rule[Any]"]: class Rule(Generic[TWorld]): """Base class for a static rule used to generate an access rule""" - options: Iterable[OptionFilter[Any]] = dataclasses.field(default=(), kw_only=True) + options: Iterable[OptionFilter] = dataclasses.field(default=(), kw_only=True) """An iterable of OptionFilters to restrict what options are required for this rule to be active""" game_name: ClassVar[str] @@ -144,7 +144,7 @@ def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": return Or(self, *other.children, options=self.options) return Or(self, other) - def __lshift__(self, other: Iterable[OptionFilter[Any]]) -> "Rule[TWorld]": + def __lshift__(self, other: Iterable[OptionFilter]) -> "Rule[TWorld]": """Convenience operator to filter an existing rule with an option filter""" return Filtered(self, options=other) @@ -298,7 +298,7 @@ class NestedRule(Rule[TWorld], game="Archipelago"): children: tuple[Rule[TWorld], ...] """The child rules this rule's logic is based on""" - def __init__(self, *children: Rule[TWorld], options: Iterable[OptionFilter[Any]] = ()) -> None: + def __init__(self, *children: Rule[TWorld], options: Iterable[OptionFilter] = ()) -> None: super().__init__(options=options) self.children = children @@ -731,7 +731,7 @@ class HasAll(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - def __init__(self, *item_names: str, options: Iterable[OptionFilter[Any]] = ()) -> None: + def __init__(self, *item_names: str, options: Iterable[OptionFilter] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) @@ -844,7 +844,7 @@ class HasAny(Rule[TWorld], game="Archipelago"): item_names: tuple[str, ...] """A tuple of item names to check for""" - def __init__(self, *item_names: str, options: Iterable[OptionFilter[Any]] = ()) -> None: + def __init__(self, *item_names: str, options: Iterable[OptionFilter] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) @@ -1170,7 +1170,7 @@ class HasFromList(Rule[TWorld], game="Archipelago"): count: int = 1 """The number of items the player needs to have""" - def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter[Any]] = ()) -> None: + def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) self.count = count @@ -1298,7 +1298,7 @@ class HasFromListUnique(Rule[TWorld], game="Archipelago"): count: int = 1 """The number of items the player needs to have""" - def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter[Any]] = ()) -> None: + def __init__(self, *item_names: str, count: int = 1, options: Iterable[OptionFilter] = ()) -> None: super().__init__(options=options) self.item_names = tuple(sorted(set(item_names))) self.count = count diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index bf751f37dadf..7e153cce781d 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -1,12 +1,12 @@ import unittest -from dataclasses import dataclass +from dataclasses import dataclass, fields from typing import Any, ClassVar from typing_extensions import override from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region from NetUtils import JSONMessagePart -from Options import Choice, Option, PerGameCommonOptions, Toggle +from Options import Choice, FreeText, Option, OptionSet, PerGameCommonOptions, Toggle from rule_builder.cached_world import CachedRuleBuilderWorld from rule_builder.options import Operator, OptionFilter from rule_builder.rules import ( @@ -40,16 +40,28 @@ class ToggleOption(Toggle): class ChoiceOption(Choice): + auto_display_name = True option_first = 0 option_second = 1 option_third = 2 default = 0 +class FreeTextOption(FreeText): + auto_display_name = True + + +class SetOption(OptionSet): + auto_display_name = True + valid_keys = {"one", "two", "three"} # noqa: RUF012 + + @dataclass class RuleBuilderOptions(PerGameCommonOptions): toggle_option: ToggleOption choice_option: ChoiceOption + text_option: FreeTextOption + set_option: SetOption GAME_NAME = "Rule Builder Test Game" @@ -231,17 +243,25 @@ def test_simplify(self) -> None: @classvar_matrix( cases=( - (ToggleOption, 0, 0, "eq", True), - (ToggleOption, 0, 1, "eq", False), - (ToggleOption, 0, 0, "ne", False), - (ToggleOption, 0, 1, "ne", True), - (ChoiceOption, 0, 1, "gt", False), - (ChoiceOption, 1, 1, "gt", False), - (ChoiceOption, 2, 1, "gt", True), + (ToggleOption, 0, "eq", 0, True), + (ToggleOption, 0, "eq", 1, False), + (ToggleOption, 0, "ne", 0, False), + (ToggleOption, 0, "ne", 1, True), + (ChoiceOption, 0, "gt", 1, False), + (ChoiceOption, 1, "gt", 1, False), + (ChoiceOption, 2, "gt", 1, True), + (ChoiceOption, 0, "ge", "second", False), + (ChoiceOption, 1, "ge", "second", True), + (ChoiceOption, 1, "in", (0, 1), True), + (ChoiceOption, 1, "in", ("first", "second"), True), + (FreeTextOption, "no", "eq", "yes", False), + (FreeTextOption, "yes", "eq", "yes", True), + (SetOption, {"one", "two"}, "contains", "three", False), + (SetOption, {"one", "two"}, "contains", "two", True), ) ) class TestOptions(unittest.TestCase): - cases: ClassVar[tuple[type[Option[Any]], Any, Any, Operator, bool]] + cases: ClassVar[tuple[type[Option[Any]], Any, Operator, Any, bool]] multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] @@ -249,16 +269,17 @@ def test_simplify(self) -> None: multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) world = multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) - option_cls, world_value, filter_value, operator, result = self.cases - expected = True_.Resolved(player=1) if result else False_.Resolved(player=1) - if option_cls is ToggleOption: - world.options.toggle_option.value = world_value - elif option_cls is ChoiceOption: - world.options.choice_option.value = world_value - - rule = True_(options=[OptionFilter(option_cls, filter_value, operator)]) - resolved_rule = rule.resolve(world) - self.assertEqual(resolved_rule, expected) + option_cls, world_value, operator, filter_value, expected = self.cases + + for field in fields(world.options_dataclass): + if field.type is option_cls: + opt = getattr(world.options, field.name) + opt.value = world_value + break + + option_filter = OptionFilter(option_cls, filter_value, operator) + result = option_filter.check(world.options) + self.assertEqual(result, expected, f"Expected {result} for option={option_filter} with value={world_value}") @classvar_matrix( From 7c76099141a41cfaa64c5466e1d076d076dba925 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 18:03:10 -0500 Subject: [PATCH 118/135] rm World = object --- rule_builder/rules.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 0d417edbf000..6c6a6c767570 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -1,6 +1,6 @@ import dataclasses from collections.abc import Callable, Iterable, Mapping -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Never, Self, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Never, Self, cast from typing_extensions import TypeVar, dataclass_transform, override @@ -14,8 +14,6 @@ TWorld = TypeVar("TWorld", bound=World, contravariant=True, default=World) # noqa: PLC0105 else: - World = object - TWorld = TypeVar("TWorld") @@ -117,7 +115,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: """Returns a new instance of this rule from a serialized dict representation""" options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(**data.get("args", {}), options=options) @@ -320,7 +318,7 @@ def to_dict(self) -> dict[str, Any]: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: children = [world_cls.rule_from_dict(c) for c in data.get("children", ())] options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(*children, options=options) @@ -582,7 +580,7 @@ def to_dict(self) -> dict[str, Any]: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: child = data.get("child") if child is None: raise ValueError("Child rule cannot be None") @@ -750,7 +748,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -863,7 +861,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1191,7 +1189,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1319,7 +1317,7 @@ def _instantiate(self, world: TWorld) -> Rule.Resolved: @override @classmethod - def from_dict(cls, data: Mapping[str, Any], world_cls: type[World]) -> Self: + def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: args = {**data.get("args", {})} item_names = args.pop("item_names", ()) options = OptionFilter.multiple_from_dict(data.get("options", ())) @@ -1766,8 +1764,8 @@ def __str__(self) -> str: return f"Can reach entrance {self.entrance_name}" -DEFAULT_RULES = { - rule_name: cast(type[Rule[World]], rule_class) +DEFAULT_RULES: "Final[dict[str, type[Rule[World]]]]" = { + rule_name: cast("type[Rule[World]]", rule_class) for rule_name, rule_class in locals().items() if isinstance(rule_class, type) and issubclass(rule_class, Rule) and rule_class is not Rule } From e6e0e1bc7c515128d58602e17169570cf21049f0 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 18:10:15 -0500 Subject: [PATCH 119/135] test fixes --- test/general/test_rule_builder.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 7e153cce781d..b9c9c9668c59 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -53,7 +53,7 @@ class FreeTextOption(FreeText): class SetOption(OptionSet): auto_display_name = True - valid_keys = {"one", "two", "three"} # noqa: RUF012 + valid_keys: ClassVar[set[str]] = {"one", "two", "three"} # pyright: ignore[reportIncompatibleVariableOverride] @dataclass @@ -256,8 +256,8 @@ def test_simplify(self) -> None: (ChoiceOption, 1, "in", ("first", "second"), True), (FreeTextOption, "no", "eq", "yes", False), (FreeTextOption, "yes", "eq", "yes", True), - (SetOption, {"one", "two"}, "contains", "three", False), - (SetOption, {"one", "two"}, "contains", "two", True), + (SetOption, ("one", "two"), "contains", "three", False), + (SetOption, ("one", "two"), "contains", "two", True), ) ) class TestOptions(unittest.TestCase): @@ -273,8 +273,7 @@ def test_simplify(self) -> None: for field in fields(world.options_dataclass): if field.type is option_cls: - opt = getattr(world.options, field.name) - opt.value = world_value + setattr(world.options, field.name, option_cls.from_any(world_value)) break option_filter = OptionFilter(option_cls, filter_value, operator) From ee1c59350149df2783f76dce4e2d4c69c4443eb4 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 18:52:04 -0500 Subject: [PATCH 120/135] move cache to logic mixin --- BaseClasses.py | 4 --- rule_builder/cached_world.py | 25 +++++++++++--- rule_builder/rules.py | 5 +-- test/general/test_rule_builder.py | 54 +++++++++++++++++-------------- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index a0c00447845c..75036fc52507 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -728,7 +728,6 @@ class CollectionState(): locations_checked: Set[Location] stale: Dict[int, bool] allow_partial_entrances: bool - rule_cache: dict[int, dict[int, bool]] additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] @@ -738,7 +737,6 @@ def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): self.multiworld = parent self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()} - self.rule_cache = {player: {} for player in parent.get_all_ids()} self.advancements = set() self.path = {} self.locations_checked = set() @@ -826,8 +824,6 @@ def copy(self) -> CollectionState: self.reachable_regions.items()} ret.blocked_connections = {player: entrance_set.copy() for player, entrance_set in self.blocked_connections.items()} - ret.rule_cache = {player: player_cache.copy() for player, player_cache in - self.rule_cache.items()} ret.advancements = self.advancements.copy() ret.path = self.path.copy() ret.locations_checked = self.locations_checked.copy() diff --git a/rule_builder/cached_world.py b/rule_builder/cached_world.py index 81e6bf07d40c..83b404abdc75 100644 --- a/rule_builder/cached_world.py +++ b/rule_builder/cached_world.py @@ -1,10 +1,10 @@ from collections import defaultdict -from typing import ClassVar +from typing import ClassVar, cast from typing_extensions import override from BaseClasses import CollectionState, Item, MultiWorld, Region -from worlds.AutoWorld import World +from worlds.AutoWorld import LogicMixin, World from .rules import Rule @@ -80,7 +80,7 @@ def register_dependencies(self) -> None: def collect(self, state: CollectionState, item: Item) -> bool: changed = super().collect(state, item) if changed and self.rule_item_dependencies: - player_results = state.rule_cache[self.player] + player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] for rule_id in rule_ids: @@ -95,7 +95,7 @@ def remove(self, state: CollectionState, item: Item) -> bool: if not changed: return changed - player_results = state.rule_cache[self.player] + player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] if self.rule_item_dependencies: mapped_name = self.item_mapping.get(item.name, "") rule_ids = self.rule_item_dependencies[item.name] | self.rule_item_dependencies[mapped_name] @@ -126,6 +126,21 @@ def remove(self, state: CollectionState, item: Item) -> bool: def reached_region(self, state: CollectionState, region: Region) -> None: super().reached_region(state, region) if self.rule_region_dependencies: - player_results = state.rule_cache[self.player] + player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] for rule_id in self.rule_region_dependencies[region.name]: player_results.pop(rule_id, None) + + +class CachedRuleBuilderLogicMixin(LogicMixin): + multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] + rule_builder_cache: dict[int, dict[int, bool]] # pyright: ignore[reportUninitializedInstanceVariable] + + def init_mixin(self, multiworld: "MultiWorld") -> None: + players = multiworld.get_all_ids() + self.rule_builder_cache = {player: {} for player in players} + + def copy_mixin(self, new_state: "CachedRuleBuilderLogicMixin") -> "CachedRuleBuilderLogicMixin": + new_state.rule_builder_cache = { + player: player_results.copy() for player, player_results in self.rule_builder_cache.items() + } + return new_state diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 6c6a6c767570..744dd376e6d7 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -205,12 +205,13 @@ def __call__(self, state: CollectionState) -> bool: if not self.caching_enabled: return self._evaluate(state) - cached_result = state.rule_cache[self.player].get(id(self)) + player_results = cast(dict[int, bool], state.rule_builder_cache[self.player]) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] + cached_result = player_results.get(id(self)) if cached_result is not None: return cached_result result = self._evaluate(state) - state.rule_cache[self.player][id(self)] = result + player_results[id(self)] = result return result def _evaluate(self, state: CollectionState) -> bool: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index b9c9c9668c59..5f8aa53ede48 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -1,6 +1,6 @@ import unittest from dataclasses import dataclass, fields -from typing import Any, ClassVar +from typing import Any, ClassVar, cast from typing_extensions import override @@ -35,6 +35,10 @@ from worlds.AutoWorld import WebWorld, World +class CachedCollectionState(CollectionState): + rule_builder_cache: dict[int, dict[int, bool]] # pyright: ignore[reportUninitializedInstanceVariable] + + class ToggleOption(Toggle): auto_display_name = True @@ -366,7 +370,7 @@ def test_has_all_hash(self) -> None: class TestCaching(unittest.TestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] world: RuleBuilderCachedWorld # pyright: ignore[reportUninitializedInstanceVariable] - state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] + state: CachedCollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override @@ -375,7 +379,7 @@ def setUp(self) -> None: world = self.multiworld.worlds[1] assert isinstance(world, RuleBuilderCachedWorld) self.world = world - self.state = self.multiworld.state + self.state = cast(CachedCollectionState, self.multiworld.state) region1 = Region("Region 1", self.player, self.multiworld) region2 = Region("Region 2", self.player, self.multiworld) @@ -405,61 +409,61 @@ def test_item_cache_busting(self) -> None: self.state.collect(self.world.create_item("Item 1")) # access to region 2 self.state.collect(self.world.create_item("Item 2")) # item directly needed self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) + self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 3")) # clears cache, item directly needed - self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) + self.assertNotIn(id(location.access_rule), self.state.rule_builder_cache[1]) self.assertTrue(location.can_reach(self.state)) - self.assertTrue(self.state.rule_cache[1][id(location.access_rule)]) + self.assertTrue(self.state.rule_builder_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 3")) # does not clear cache as rule is already true - self.assertTrue(self.state.rule_cache[1][id(location.access_rule)]) + self.assertTrue(self.state.rule_builder_cache[1][id(location.access_rule)]) def test_region_cache_busting(self) -> None: location = self.world.get_location("Location 2") self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) + self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for region 2 access # cache gets cleared during the can_reach self.assertTrue(location.can_reach(self.state)) - self.assertTrue(self.state.rule_cache[1][id(location.access_rule)]) + self.assertTrue(self.state.rule_builder_cache[1][id(location.access_rule)]) def test_location_cache_busting(self) -> None: location = self.world.get_location("Location 5") self.state.collect(self.world.create_item("Item 1")) # access to region 2 self.state.collect(self.world.create_item("Item 3")) # access to region 3 self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) + self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 2")) # clears cache, item only needed for location 2 access - self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) + self.assertNotIn(id(location.access_rule), self.state.rule_builder_cache[1]) self.assertTrue(location.can_reach(self.state)) def test_entrance_cache_busting(self) -> None: location = self.world.get_location("Location 6") self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule self.assertFalse(location.can_reach(self.state)) # populates cache - self.assertFalse(self.state.rule_cache[1][id(location.access_rule)]) + self.assertFalse(self.state.rule_builder_cache[1][id(location.access_rule)]) self.state.collect(self.world.create_item("Item 1")) # clears cache, item only needed for entrance access - self.assertNotIn(id(location.access_rule), self.state.rule_cache[1]) + self.assertNotIn(id(location.access_rule), self.state.rule_builder_cache[1]) self.assertTrue(location.can_reach(self.state)) def test_has_skips_cache(self) -> None: entrance = self.world.get_entrance("Region 1 -> Region 2") self.assertFalse(entrance.can_reach(self.state)) # does not populates cache - self.assertNotIn(id(entrance.access_rule), self.state.rule_cache[1]) + self.assertNotIn(id(entrance.access_rule), self.state.rule_builder_cache[1]) self.state.collect(self.world.create_item("Item 1")) # no need to clear cache, item directly needed - self.assertNotIn(id(entrance.access_rule), self.state.rule_cache[1]) + self.assertNotIn(id(entrance.access_rule), self.state.rule_builder_cache[1]) self.assertTrue(entrance.can_reach(self.state)) class TestCacheDisabled(unittest.TestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] - state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] + state: CachedCollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override @@ -468,7 +472,7 @@ def setUp(self) -> None: world = self.multiworld.worlds[1] assert isinstance(world, RuleBuilderWorld) self.world = world - self.state = self.multiworld.state + self.state = cast(CachedCollectionState, self.multiworld.state) region1 = Region("Region 1", self.player, self.multiworld) region2 = Region("Region 2", self.player, self.multiworld) @@ -494,41 +498,41 @@ def setUp(self) -> None: def test_item_logic(self) -> None: entrance = self.world.get_entrance("Region 1 -> Region 2") self.assertFalse(entrance.can_reach(self.state)) - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.state.collect(self.world.create_item("Item 1")) # item directly needed - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.assertTrue(entrance.can_reach(self.state)) def test_region_logic(self) -> None: location = self.world.get_location("Location 2") self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule self.assertFalse(location.can_reach(self.state)) - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.state.collect(self.world.create_item("Item 1")) # item only needed for region 2 access self.assertTrue(location.can_reach(self.state)) - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) def test_location_logic(self) -> None: location = self.world.get_location("Location 5") self.state.collect(self.world.create_item("Item 1")) # access to region 2 self.state.collect(self.world.create_item("Item 3")) # access to region 3 self.assertFalse(location.can_reach(self.state)) - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.state.collect(self.world.create_item("Item 2")) # item only needed for location 2 access - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.assertTrue(location.can_reach(self.state)) def test_entrance_logic(self) -> None: location = self.world.get_location("Location 6") self.state.collect(self.world.create_item("Item 2")) # item directly needed for location rule self.assertFalse(location.can_reach(self.state)) - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.state.collect(self.world.create_item("Item 1")) # item only needed for entrance access - self.assertFalse(self.state.rule_cache[1]) + self.assertFalse(self.state.rule_builder_cache[1]) self.assertTrue(location.can_reach(self.state)) From 02176194304c3d584e542af6348e480fcbf5e78b Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 19:35:56 -0500 Subject: [PATCH 121/135] keep test rule builder world out of global registry --- test/general/test_rule_builder.py | 194 +++++++++++++++--------------- 1 file changed, 95 insertions(+), 99 deletions(-) diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 5f8aa53ede48..870ab1d3e2fd 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -31,8 +31,7 @@ ) from test.general import setup_solo_multiworld from test.param import classvar_matrix -from worlds import network_data_package -from worlds.AutoWorld import WebWorld, World +from worlds.AutoWorld import AutoWorldRegister, World class CachedCollectionState(CollectionState): @@ -69,14 +68,9 @@ class RuleBuilderOptions(PerGameCommonOptions): GAME_NAME = "Rule Builder Test Game" -GAME_NAME_CACHED = "Rule Builder Cached Test Game" LOC_COUNT = 20 -class RuleBuilderWebWorld(WebWorld): - tutorials = [] # noqa: RUF012 - - class RuleBuilderItem(Item): game = GAME_NAME @@ -85,64 +79,72 @@ class RuleBuilderLocation(Location): game = GAME_NAME -class RuleBuilderWorld(World): - game = GAME_NAME - web = RuleBuilderWebWorld() - item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} - location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} - item_name_groups: ClassVar[dict[str, set[str]]] = { - "Group 1": {"Item 1", "Item 2", "Item 3"}, - "Group 2": {"Item 4", "Item 5"}, - } - hidden = True - options_dataclass = RuleBuilderOptions - options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] - origin_region_name = "Region 1" +class RuleBuilderTestCase(unittest.TestCase): + old_world_types: dict[str, type[World]] # pyright: ignore[reportUninitializedInstanceVariable] + world_cls: type[World] # pyright: ignore[reportUninitializedInstanceVariable] @override - def create_item(self, name: str) -> RuleBuilderItem: - classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression - return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) - - @override - def get_filler_item_name(self) -> str: - return "Filler" - - -class RuleBuilderCachedItem(Item): - game = GAME_NAME_CACHED - - -class RuleBuilderCachedLocation(Location): - game = GAME_NAME_CACHED - - -class RuleBuilderCachedWorld(CachedRuleBuilderWorld): - game = GAME_NAME_CACHED - web = RuleBuilderWebWorld() - item_name_to_id: ClassVar[dict[str, int]] = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} - location_name_to_id: ClassVar[dict[str, int]] = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} - item_name_groups: ClassVar[dict[str, set[str]]] = { - "Group 1": {"Item 1", "Item 2", "Item 3"}, - "Group 2": {"Item 4", "Item 5"}, - } - hidden = True - options_dataclass = RuleBuilderOptions - options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] - origin_region_name = "Region 1" + def setUp(self) -> None: + self.old_world_types = AutoWorldRegister.world_types.copy() + self._create_world_class() @override - def create_item(self, name: str) -> RuleBuilderCachedItem: - classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression - return RuleBuilderCachedItem(name, classification, self.item_name_to_id[name], self.player) - + def tearDown(self) -> None: + AutoWorldRegister.world_types = self.old_world_types + assert GAME_NAME not in AutoWorldRegister.world_types + + def _create_world_class(self) -> None: + class RuleBuilderWorld(World): + game = GAME_NAME + item_name_to_id: ClassVar = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} + location_name_to_id: ClassVar = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} + item_name_groups: ClassVar = { + "Group 1": {"Item 1", "Item 2", "Item 3"}, + "Group 2": {"Item 4", "Item 5"}, + } + hidden = True + options_dataclass = RuleBuilderOptions + options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] + origin_region_name = "Region 1" + + @override + def create_item(self, name: str) -> RuleBuilderItem: + classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression + return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) + + @override + def get_filler_item_name(self) -> str: + return "Filler" + + self.world_cls = RuleBuilderWorld + + +class CachedRuleBuilderTestCase(RuleBuilderTestCase): @override - def get_filler_item_name(self) -> str: - return "Filler" - - -network_data_package["games"][RuleBuilderWorld.game] = RuleBuilderWorld.get_data_package_data() -network_data_package["games"][RuleBuilderCachedWorld.game] = RuleBuilderCachedWorld.get_data_package_data() + def _create_world_class(self) -> None: + class RuleBuilderWorld(CachedRuleBuilderWorld): + game = GAME_NAME + item_name_to_id: ClassVar = {f"Item {i}": i for i in range(1, LOC_COUNT + 1)} + location_name_to_id: ClassVar = {f"Location {i}": i for i in range(1, LOC_COUNT + 1)} + item_name_groups: ClassVar = { + "Group 1": {"Item 1", "Item 2", "Item 3"}, + "Group 2": {"Item 4", "Item 5"}, + } + hidden = True + options_dataclass = RuleBuilderOptions + options: RuleBuilderOptions # pyright: ignore[reportIncompatibleVariableOverride] + origin_region_name = "Region 1" + + @override + def create_item(self, name: str) -> RuleBuilderItem: + classification = ItemClassification.filler if name == "Filler" else ItemClassification.progression + return RuleBuilderItem(name, classification, self.item_name_to_id[name], self.player) + + @override + def get_filler_item_name(self) -> str: + return "Filler" + + self.world_cls = RuleBuilderWorld @classvar_matrix( @@ -233,13 +235,12 @@ def get_filler_item_name(self) -> str: ), ) ) -class TestSimplify(unittest.TestCase): +class TestSimplify(RuleBuilderTestCase): rules: ClassVar[tuple[Rule[Any], Rule.Resolved]] def test_simplify(self) -> None: - multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) world = multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) rule, expected = self.rules resolved_rule = rule.resolve(world) self.assertEqual(resolved_rule, expected, f"\n{resolved_rule}\n{expected}") @@ -264,15 +265,14 @@ def test_simplify(self) -> None: (SetOption, ("one", "two"), "contains", "two", True), ) ) -class TestOptions(unittest.TestCase): +class TestOptions(RuleBuilderTestCase): cases: ClassVar[tuple[type[Option[Any]], Any, Operator, Any, bool]] multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: World # pyright: ignore[reportUninitializedInstanceVariable] def test_simplify(self) -> None: - multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) world = multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) option_cls, world_value, operator, filter_value, expected = self.cases for field in fields(world.options_dataclass): @@ -341,14 +341,14 @@ def test_simplify(self) -> None: ) ) class TestComposition(unittest.TestCase): - rules: ClassVar[tuple[Rule[RuleBuilderWorld], Rule[RuleBuilderWorld]]] + rules: ClassVar[tuple[Rule[Any], Rule[Any]]] def test_composition(self) -> None: combined_rule, expected = self.rules self.assertEqual(combined_rule, expected, str(combined_rule)) -class TestHashes(unittest.TestCase): +class TestHashes(RuleBuilderTestCase): def test_and_hash(self) -> None: rule1 = And.Resolved((True_.Resolved(player=1),), player=1) rule2 = And.Resolved((True_.Resolved(player=1),), player=1) @@ -358,26 +358,25 @@ def test_and_hash(self) -> None: self.assertNotEqual(hash(rule1), hash(rule3)) def test_has_all_hash(self) -> None: - multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=("generate_early",), seed=0) + multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) world = multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) - rule1 = HasAll("1", "2") rule2 = HasAll("2", "2", "2", "1") self.assertEqual(hash(rule1.resolve(world)), hash(rule2.resolve(world))) -class TestCaching(unittest.TestCase): +class TestCaching(CachedRuleBuilderTestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: RuleBuilderCachedWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: World # pyright: ignore[reportUninitializedInstanceVariable] state: CachedCollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override def setUp(self) -> None: - self.multiworld = setup_solo_multiworld(RuleBuilderCachedWorld, seed=0) + super().setUp() + + self.multiworld = setup_solo_multiworld(self.world_cls, seed=0) world = self.multiworld.worlds[1] - assert isinstance(world, RuleBuilderCachedWorld) self.world = world self.state = cast(CachedCollectionState, self.multiworld.state) @@ -386,9 +385,9 @@ def setUp(self) -> None: region3 = Region("Region 3", self.player, self.multiworld) self.multiworld.regions.extend([region1, region2, region3]) - region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderCachedLocation) - region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderCachedLocation) - region3.add_locations({"Location 5": 5}, RuleBuilderCachedLocation) + region1.add_locations({"Location 1": 1, "Location 2": 2, "Location 6": 6}, RuleBuilderLocation) + region2.add_locations({"Location 3": 3, "Location 4": 4}, RuleBuilderLocation) + region3.add_locations({"Location 5": 5}, RuleBuilderLocation) world.create_entrance(region1, region2, Has("Item 1")) world.create_entrance(region1, region3, HasAny("Item 3", "Item 4")) @@ -402,8 +401,6 @@ def setUp(self) -> None: world.register_dependencies() - return super().setUp() - def test_item_cache_busting(self) -> None: location = self.world.get_location("Location 4") self.state.collect(self.world.create_item("Item 1")) # access to region 2 @@ -460,17 +457,18 @@ def test_has_skips_cache(self) -> None: self.assertTrue(entrance.can_reach(self.state)) -class TestCacheDisabled(unittest.TestCase): +class TestCacheDisabled(RuleBuilderTestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: World # pyright: ignore[reportUninitializedInstanceVariable] state: CachedCollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override def setUp(self) -> None: - self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + super().setUp() + + self.multiworld = setup_solo_multiworld(self.world_cls, seed=0) world = self.multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) self.world = world self.state = cast(CachedCollectionState, self.multiworld.state) @@ -493,8 +491,6 @@ def setUp(self) -> None: for i in range(1, LOC_COUNT + 1): self.multiworld.itempool.append(world.create_item(f"Item {i}")) - return super().setUp() - def test_item_logic(self) -> None: entrance = self.world.get_entrance("Region 1 -> Region 2") self.assertFalse(entrance.can_reach(self.state)) @@ -536,17 +532,18 @@ def test_entrance_logic(self) -> None: self.assertTrue(location.can_reach(self.state)) -class TestRules(unittest.TestCase): +class TestRules(RuleBuilderTestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: World # pyright: ignore[reportUninitializedInstanceVariable] state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @override def setUp(self) -> None: - self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + super().setUp() + + self.multiworld = setup_solo_multiworld(self.world_cls, seed=0) world = self.multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) self.world = world self.state = self.multiworld.state @@ -718,7 +715,7 @@ def test_completion_rule(self) -> None: self.assertEqual(self.multiworld.can_beat_game(self.state), True) -class TestSerialization(unittest.TestCase): +class TestSerialization(RuleBuilderTestCase): maxDiff: int | None = None rule: ClassVar[Rule[Any]] = And( @@ -860,17 +857,15 @@ def test_serialize(self) -> None: self.assertDictEqual(serialized_rule, self.rule_dict) def test_deserialize(self) -> None: - multiworld = setup_solo_multiworld(RuleBuilderWorld, steps=(), seed=0) + multiworld = setup_solo_multiworld(self.world_cls, steps=(), seed=0) world = multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) - deserialized_rule = world.rule_from_dict(self.rule_dict) self.assertEqual(deserialized_rule, self.rule, str(deserialized_rule)) -class TestExplain(unittest.TestCase): +class TestExplain(RuleBuilderTestCase): multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: RuleBuilderWorld # pyright: ignore[reportUninitializedInstanceVariable] + world: World # pyright: ignore[reportUninitializedInstanceVariable] state: CollectionState # pyright: ignore[reportUninitializedInstanceVariable] player: int = 1 @@ -901,9 +896,10 @@ class TestExplain(unittest.TestCase): @override def setUp(self) -> None: - self.multiworld = setup_solo_multiworld(RuleBuilderWorld, seed=0) + super().setUp() + + self.multiworld = setup_solo_multiworld(self.world_cls, seed=0) world = self.multiworld.worlds[1] - assert isinstance(world, RuleBuilderWorld) self.world = world self.state = self.multiworld.state From 05b802129df8378bd976dec9314672bf85f5dc58 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 20:23:00 -0500 Subject: [PATCH 122/135] fix OptionFilter compat --- worlds/astalon/logic/custom_rules.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 8a88892a65aa..c72c7b0783af 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -1,7 +1,7 @@ import dataclasses from collections.abc import Iterable from enum import Enum -from typing import Any, ClassVar, cast +from typing import ClassVar, cast from typing_extensions import override @@ -53,7 +53,7 @@ def __init__( item_name: ItemName | Events, count: int = 1, *, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(as_str(item_name), count, options=options) @@ -64,7 +64,7 @@ class HasAll(rules.HasAll[AstalonWorldBase], game=GAME_NAME): def __init__( self, *item_names: ItemName | Events, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: names = [as_str(name) for name in item_names] if len(names) != len(set(names)): @@ -79,7 +79,7 @@ class HasAny(rules.HasAny[AstalonWorldBase], game=GAME_NAME): def __init__( self, *item_names: ItemName | Events, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: names = [as_str(name) for name in item_names] if len(names) != len(set(names)): @@ -97,7 +97,7 @@ def __init__( parent_region_name: RegionName | None = None, skip_indirect_connection: bool = False, *, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(as_str(location_name), as_str(parent_region_name), skip_indirect_connection, options=options) @@ -109,7 +109,7 @@ def __init__( self, region_name: RegionName, *, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(as_str(region_name), options=options) @@ -122,7 +122,7 @@ def __init__( from_region: RegionName, to_region: RegionName, *, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: entrance_name = f"{as_str(from_region)} -> {as_str(to_region)}" super().__init__(entrance_name, as_str(from_region), options=options) @@ -158,7 +158,7 @@ def __init__( self, *doors: WhiteDoor, otherwise: bool = False, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -172,7 +172,7 @@ def __init__( self, *doors: BlueDoor, otherwise: bool = False, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -186,7 +186,7 @@ def __init__( self, *doors: RedDoor, otherwise: bool = False, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(*doors, options=options) self.otherwise: bool = otherwise @@ -200,7 +200,7 @@ def __init__( self, *switches: Switch | Crystal | Face, otherwise: bool = False, - options: Iterable[OptionFilter[Any]] = (), + options: Iterable[OptionFilter] = (), ) -> None: super().__init__(*switches, options=options) self.otherwise: bool = otherwise @@ -208,7 +208,7 @@ def __init__( @dataclasses.dataclass(init=False) class HasElevator(HasAll, game=GAME_NAME): - def __init__(self, elevator: Elevator, *, options: Iterable[OptionFilter[Any]] = ()) -> None: + def __init__(self, elevator: Elevator, *, options: Iterable[OptionFilter] = ()) -> None: super().__init__( KeyItem.ASCENDANT_KEY, elevator, From 623ce106f78c737b8980d1e3e58ea98c21704d0d Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Wed, 14 Jan 2026 20:35:24 -0500 Subject: [PATCH 123/135] todone --- rule_builder/rules.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 744dd376e6d7..4a7b93ae5dd3 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -163,7 +163,6 @@ def __init_subclass__(cls, /, game: str) -> None: raise TypeError(f"Rule {cls.__qualname__} has already been registered for game {game}") custom_rules[cls.__qualname__] = cls elif cls.__module__ != "rule_builder.rules": - # TODO: test to make sure this works on frozen raise TypeError("You cannot define custom rules for the base Archipelago world") cls.game_name = game From 02559d39c725f57ac416f96c3e6cdef0dff3019e Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Mon, 2 Feb 2026 12:46:58 -0500 Subject: [PATCH 124/135] call register_dependencies automatically --- Main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Main.py b/Main.py index 47a28813fce4..8c9f54b5dba2 100644 --- a/Main.py +++ b/Main.py @@ -9,6 +9,7 @@ import zipfile import zlib +from rule_builder.cached_world import CachedRuleBuilderWorld import worlds from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ @@ -117,6 +118,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) logger.info('Calculating Access Rules.') AutoWorld.call_all(multiworld, "set_rules") + # Convenience for CachedRuleBuilderWorld users: Ensure that caching setup function is called + # Can be removed once dependency system is improved + for player, world in multiworld.worlds.items(): + if isinstance(world, CachedRuleBuilderWorld): + AutoWorld.call_single(multiworld, "register_dependencies", player) + for player in multiworld.player_ids: exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value From bfb661966ec2a096c9df5caf2ef4b4d904c8fd9a Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 7 Feb 2026 12:14:23 -0500 Subject: [PATCH 125/135] move register deps call to call_single --- Main.py | 7 ------- docs/rule builder.md | 4 +--- rule_builder/cached_world.py | 2 +- test/general/test_rule_builder.py | 2 +- worlds/AutoWorld.py | 7 ++++++- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/Main.py b/Main.py index 8c9f54b5dba2..47a28813fce4 100644 --- a/Main.py +++ b/Main.py @@ -9,7 +9,6 @@ import zipfile import zlib -from rule_builder.cached_world import CachedRuleBuilderWorld import worlds from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ @@ -118,12 +117,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) logger.info('Calculating Access Rules.') AutoWorld.call_all(multiworld, "set_rules") - # Convenience for CachedRuleBuilderWorld users: Ensure that caching setup function is called - # Can be removed once dependency system is improved - for player, world in multiworld.worlds.items(): - if isinstance(world, CachedRuleBuilderWorld): - AutoWorld.call_single(multiworld, "register_dependencies", player) - for player in multiworld.player_ids: exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value diff --git a/docs/rule builder.md b/docs/rule builder.md index b20a23eba104..d2b382684c69 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -140,8 +140,6 @@ class MyWorld(CachedRuleBuilderWorld): 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 you enable caching and your rules use `CanReachLocation`, `CanReachEntrance` or a custom rule that depends on locations or entrances, you must call `self.register_dependencies()` after all of your locations and entrances exist to setup the caching system. - ### 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. @@ -451,7 +449,7 @@ 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_dependencies()`: Register all rules that depend on location or entrance access with the inherited dependencies +- `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` diff --git a/rule_builder/cached_world.py b/rule_builder/cached_world.py index 83b404abdc75..bb7cc4d9b5a3 100644 --- a/rule_builder/cached_world.py +++ b/rule_builder/cached_world.py @@ -50,7 +50,7 @@ def register_rule_dependencies(self, resolved_rule: Rule.Resolved) -> None: for entrance_name, rule_ids in resolved_rule.entrance_dependencies().items(): self.rule_entrance_dependencies[entrance_name] |= rule_ids - def register_dependencies(self) -> None: + def register_rule_builder_dependencies(self) -> None: """Register all rules that depend on locations or entrances with their dependencies""" for location_name, rule_ids in self.rule_location_dependencies.items(): try: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 870ab1d3e2fd..e50d05b61c7a 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -399,7 +399,7 @@ def setUp(self) -> None: for i in range(1, LOC_COUNT + 1): self.multiworld.itempool.append(world.create_item(f"Item {i}")) - world.register_dependencies() + world.register_rule_builder_dependencies() def test_item_cache_busting(self) -> None: location = self.world.get_location("Location 4") diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 2802151cb7dc..327e386c05f1 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -178,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: @@ -189,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 From 63fa1696acfdb23c47e193a57c3a6b57f3523428 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 7 Feb 2026 14:07:14 -0500 Subject: [PATCH 126/135] add filtered_resolution --- docs/rule builder.md | 9 ++++----- rule_builder/rules.py | 5 ++++- test/general/test_rule_builder.py | 29 ++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index d2b382684c69..6612689486dc 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -89,6 +89,8 @@ If you want a comparison that isn't equals, you can specify with the `operator` - `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: ```python @@ -98,15 +100,12 @@ rule = ( ) ``` -To add an extra logic requirement on the easiest difficulty: +To add an extra logic requirement on the easiest difficulty which is ignored for other difficulties: ```python rule = ( # ...the rest of the logic - & ( - Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)]) - | True_(options=[OptionFilter(Difficulty, Difficulty.option_medium, operator="ge")]) - ) + & Has("QoL item", options=[OptionFilter(Difficulty, Difficulty.option_easy)], filtered_resolution=True) ) ``` diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 4a7b93ae5dd3..930132d78749 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -85,6 +85,9 @@ class Rule(Generic[TWorld]): options: Iterable[OptionFilter] = dataclasses.field(default=(), kw_only=True) """An iterable of OptionFilters to restrict what options are required for this rule to be active""" + filtered_resolution: bool = dataclasses.field(default=False, kw_only=True) + """If this rule should default to True or False when filtered by its options""" + game_name: ClassVar[str] """The name of the game this rule belongs to, default rules belong to 'Archipelago'""" @@ -100,7 +103,7 @@ def resolve(self, world: TWorld) -> "Resolved": """Resolve a rule with the given world""" for option_filter in self.options: if not option_filter.check(world.options): - return False_().resolve(world) + return True_().resolve(world) if self.filtered_resolution else False_().resolve(world) return self._instantiate(world) def to_dict(self) -> dict[str, Any]: diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index e50d05b61c7a..bfad43068878 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -267,10 +267,8 @@ def test_simplify(self) -> None: ) class TestOptions(RuleBuilderTestCase): cases: ClassVar[tuple[type[Option[Any]], Any, Operator, Any, bool]] - multiworld: MultiWorld # pyright: ignore[reportUninitializedInstanceVariable] - world: World # pyright: ignore[reportUninitializedInstanceVariable] - def test_simplify(self) -> None: + def test_option_resolution(self) -> None: multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) world = multiworld.worlds[1] option_cls, world_value, operator, filter_value, expected = self.cases @@ -285,6 +283,31 @@ def test_simplify(self) -> None: self.assertEqual(result, expected, f"Expected {result} for option={option_filter} with value={world_value}") +class TestFilteredResolution(RuleBuilderTestCase): + def test_filtered_resolution(self) -> None: + multiworld = setup_solo_multiworld(self.world_cls, steps=("generate_early",), seed=0) + world = multiworld.worlds[1] + + rule_and_false = Has("A") & Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=False) + rule_and_true = Has("A") & Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=True) + rule_or_false = Has("A") | Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=False) + rule_or_true = Has("A") | Has("B", options=[OptionFilter(ToggleOption, 1)], filtered_resolution=True) + + # option fails check + world.options.toggle_option.value = 0 # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + self.assertEqual(rule_and_false.resolve(world), False_.Resolved(player=1)) + self.assertEqual(rule_and_true.resolve(world), Has.Resolved("A", player=1)) + self.assertEqual(rule_or_false.resolve(world), Has.Resolved("A", player=1)) + self.assertEqual(rule_or_true.resolve(world), True_.Resolved(player=1)) + + # option passes check + world.options.toggle_option.value = 1 # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue] + self.assertEqual(rule_and_false.resolve(world), HasAll.Resolved(("A", "B"), player=1)) + self.assertEqual(rule_and_true.resolve(world), HasAll.Resolved(("A", "B"), player=1)) + self.assertEqual(rule_or_false.resolve(world), HasAny.Resolved(("A", "B"), player=1)) + self.assertEqual(rule_or_true.resolve(world), HasAny.Resolved(("A", "B"), player=1)) + + @classvar_matrix( rules=( ( From 138a429ad230670bc211feffb089cf332c5e8eed Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 7 Feb 2026 14:17:04 -0500 Subject: [PATCH 127/135] allow bool opts on filters --- docs/rule builder.md | 5 +++-- rule_builder/rules.py | 24 ++++++++++++++++-------- test/general/test_rule_builder.py | 6 +++++- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/rule builder.md b/docs/rule builder.md index 6612689486dc..4f9102a2ba0e 100644 --- a/docs/rule builder.md +++ b/docs/rule builder.md @@ -120,12 +120,13 @@ rule = ( ) ``` -You can also use the "shovel" operator `<<` as shorthand: +You can also use the & and | operators to apply options to rules: ```python common_rule = Has("A") easy_filter = [OptionFilter(Difficulty, Difficulty.option_easy)] -common_rule_only_on_easy = common_rule << easy_filter +common_rule_only_on_easy = common_rule & easy_filter +common_rule_skipped_on_easy = common_rule | easy_filter ``` ## Enabling caching diff --git a/rule_builder/rules.py b/rule_builder/rules.py index 930132d78749..ad407bdde03c 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -123,8 +123,14 @@ def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls(**data.get("args", {}), options=options) - def __and__(self, other: "Rule[Any]") -> "Rule[TWorld]": - """Combines two rules into an And rule""" + def __and__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": + """Combines two rules or a rule and an option filter into an And rule""" + if isinstance(other, OptionFilter): + other = (other,) + if isinstance(other, Iterable): + if not other: + return self + return Filtered(self, options=other) if self.options == other.options: if isinstance(self, And): if isinstance(other, And): @@ -134,8 +140,14 @@ def __and__(self, other: "Rule[Any]") -> "Rule[TWorld]": return And(self, *other.children, options=other.options) return And(self, other) - def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": - """Combines two rules into an Or rule""" + def __or__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": + """Combines two rules or a rule and an option filter into an Or rule""" + if isinstance(other, OptionFilter): + other = (other,) + if isinstance(other, Iterable): + if not other: + return self + return Or(self, True_(options=other)) if self.options == other.options: if isinstance(self, Or): if isinstance(other, Or): @@ -145,10 +157,6 @@ def __or__(self, other: "Rule[Any]") -> "Rule[TWorld]": return Or(self, *other.children, options=self.options) return Or(self, other) - def __lshift__(self, other: Iterable[OptionFilter]) -> "Rule[TWorld]": - """Convenience operator to filter an existing rule with an option filter""" - return Filtered(self, options=other) - def __bool__(self) -> Never: """Safeguard to prevent devs from mistakenly doing `rule1 and rule2` and getting the wrong result""" raise TypeError("Use & or | to combine rules, or use `is not None` for boolean tests") diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index bfad43068878..52d85edd8b3b 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -358,9 +358,13 @@ def test_filtered_resolution(self) -> None: And(Has("A"), And(Has("B"), options=[OptionFilter(ToggleOption, 1)])), ), ( - (Has("A") | Has("B")) << [OptionFilter(ToggleOption, 1)], + (Has("A") | Has("B")) & [OptionFilter(ToggleOption, 1)], Filtered(Or(Has("A"), Has("B")), options=[OptionFilter(ToggleOption, 1)]), ), + ( + (Has("A") | Has("B")) | OptionFilter(ToggleOption, 1), + Or(Or(Has("A"), Has("B")), True_(options=[OptionFilter(ToggleOption, 1)])), + ), ) ) class TestComposition(unittest.TestCase): From d4aadb69c1a88a1854862edfbd25c347242ffe5b Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 7 Feb 2026 14:21:09 -0500 Subject: [PATCH 128/135] fix serialization tests --- rule_builder/rules.py | 7 +++++-- test/general/test_rule_builder.py | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/rule_builder/rules.py b/rule_builder/rules.py index ad407bdde03c..8a3f3deadd84 100644 --- a/rule_builder/rules.py +++ b/rule_builder/rules.py @@ -109,11 +109,14 @@ def resolve(self, world: TWorld) -> "Resolved": def to_dict(self) -> dict[str, Any]: """Returns a JSON compatible dict representation of this rule""" args = { - field.name: getattr(self, field.name, None) for field in dataclasses.fields(self) if field.name != "options" + field.name: getattr(self, field.name, None) + for field in dataclasses.fields(self) + if field.name not in ("options", "filtered_resolution") } return { "rule": self.__class__.__qualname__, "options": [o.to_dict() for o in self.options], + "filtered_resolution": self.filtered_resolution, "args": args, } @@ -121,7 +124,7 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: Mapping[str, Any], world_cls: "type[World]") -> Self: """Returns a new instance of this rule from a serialized dict representation""" options = OptionFilter.multiple_from_dict(data.get("options", ())) - return cls(**data.get("args", {}), options=options) + return cls(**data.get("args", {}), options=options, filtered_resolution=data.get("filtered_resolution", False)) def __and__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": """Combines two rules or a rule and an option filter into an And rule""" diff --git a/test/general/test_rule_builder.py b/test/general/test_rule_builder.py index 52d85edd8b3b..3e494a6ecba8 100644 --- a/test/general/test_rule_builder.py +++ b/test/general/test_rule_builder.py @@ -754,7 +754,11 @@ class TestSerialization(RuleBuilderTestCase): ), Or( HasAll("i7", "i8"), - HasAllCounts({"i9": 1, "i10": 5}, options=[OptionFilter(ToggleOption, 1, operator="ne")]), + HasAllCounts( + {"i9": 1, "i10": 5}, + options=[OptionFilter(ToggleOption, 1, operator="ne")], + filtered_resolution=True, + ), CanReachRegion("r1"), HasGroup("g1"), ), @@ -774,6 +778,7 @@ class TestSerialization(RuleBuilderTestCase): rule_dict: ClassVar[dict[str, Any]] = { "rule": "And", "options": [], + "filtered_resolution": False, "children": [ { "rule": "Or", @@ -784,20 +789,24 @@ class TestSerialization(RuleBuilderTestCase): "operator": "eq", }, ], + "filtered_resolution": False, "children": [ { "rule": "Has", "options": [], + "filtered_resolution": False, "args": {"item_name": "i1", "count": 4}, }, { "rule": "HasFromList", "options": [], + "filtered_resolution": False, "args": {"item_names": ("i2", "i3", "i4"), "count": 2}, }, { "rule": "HasAnyCount", "options": [], + "filtered_resolution": False, "args": {"item_counts": {"i5": 2, "i6": 3}}, }, ], @@ -805,10 +814,12 @@ class TestSerialization(RuleBuilderTestCase): { "rule": "Or", "options": [], + "filtered_resolution": False, "children": [ { "rule": "HasAll", "options": [], + "filtered_resolution": False, "args": {"item_names": ("i7", "i8")}, }, { @@ -820,16 +831,19 @@ class TestSerialization(RuleBuilderTestCase): "operator": "ne", }, ], + "filtered_resolution": True, "args": {"item_counts": {"i9": 1, "i10": 5}}, }, { "rule": "CanReachRegion", "options": [], + "filtered_resolution": False, "args": {"region_name": "r1"}, }, { "rule": "HasGroup", "options": [], + "filtered_resolution": False, "args": {"item_name_group": "g1", "count": 1}, }, ], @@ -848,20 +862,24 @@ class TestSerialization(RuleBuilderTestCase): "operator": "ge", }, ], + "filtered_resolution": False, "children": [ { "rule": "HasAny", "options": [], + "filtered_resolution": False, "args": {"item_names": ("i11", "i12")}, }, { "rule": "CanReachLocation", "options": [], + "filtered_resolution": False, "args": {"location_name": "l1", "parent_region_name": "r2", "skip_indirect_connection": False}, }, { "rule": "HasFromListUnique", "options": [], + "filtered_resolution": False, "args": {"item_names": ("i13", "i14"), "count": 1}, }, ], @@ -869,11 +887,13 @@ class TestSerialization(RuleBuilderTestCase): { "rule": "CanReachEntrance", "options": [], + "filtered_resolution": False, "args": {"entrance_name": "e1", "parent_region_name": ""}, }, { "rule": "HasGroupUnique", "options": [], + "filtered_resolution": False, "args": {"item_name_group": "g2", "count": 5}, }, ], From b9f5c44249334d96d99626a0005af6bae9bd89df Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sat, 7 Feb 2026 14:52:00 -0500 Subject: [PATCH 129/135] no more shovel --- worlds/astalon/logic/main_campaign.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 0726623bb6ec..1625552b4e56 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -100,8 +100,8 @@ can_big_magic = HardLogic(has_algus_arcanist & has_banish) can_kill_ghosts = has_banish | has_block | (has_algus_meteor & chalice_on_easy) -otherwise_crystal = can_crystal << switch_off -otherwise_bow = has_bow << switch_off +otherwise_crystal = can_crystal & switch_off +otherwise_bow = has_bow & switch_off elevator_apex = HasElevator( Elevator.APEX, @@ -347,7 +347,7 @@ (R.MECH_CD_ACCESS, R.CD_START): Has(KeyItem.CYCLOPS), (R.MECH_TOP, R.MECH_TRIPLE_SWITCHES): ( can_crystal - & (HasSwitch(Switch.MECH_ARIAS_CYCLOPS) | (has_arias << switch_off)) + & (HasSwitch(Switch.MECH_ARIAS_CYCLOPS) | (has_arias & switch_off)) & ( HasWhite(WhiteDoor.MECH_TOP) | Filtered(can_extra_height & (HasSwitch(Crystal.MECH_TOP) | otherwise_crystal), options=white_off) @@ -521,7 +521,7 @@ HasBlue(BlueDoor.HOTP_MAIDEN, otherwise=True) & (has_sword | (has_kyuli & has_block & Has(KeyItem.BELL))) ), (R.HOTP_BOSS_CAMPFIRE, R.HOTP_TP_PUZZLE): Has(Eye.GREEN), - (R.HOTP_BOSS_CAMPFIRE, R.HOTP_BOSS): HasWhite(WhiteDoor.HOTP_BOSS) | (has_arias << white_off), + (R.HOTP_BOSS_CAMPFIRE, R.HOTP_BOSS): HasWhite(WhiteDoor.HOTP_BOSS) | (has_arias & white_off), (R.HOTP_TP_PUZZLE, R.HOTP_TP_FALL_TOP): has_star | HasSwitch(Switch.HOTP_TP_PUZZLE, otherwise=True), (R.HOTP_TP_FALL_TOP, R.HOTP_FALL_BOTTOM): has_cloak, (R.HOTP_TP_FALL_TOP, R.HOTP_TP_PUZZLE): has_star | HasSwitch(Switch.HOTP_TP_PUZZLE), @@ -864,7 +864,7 @@ (R.SP_STAR, R.SP_SHAFT): Has(KeyItem.BELL) & has_algus_meteor & chalice_on_easy & HasSwitch(Crystal.SP_STAR), (R.SP_STAR, R.SP_STAR_CONNECTION): has_star, (R.SP_STAR_CONNECTION, R.SP_STAR): has_star, - (R.SP_STAR_CONNECTION, R.SP_STAR_END): has_star & (HasSwitch(Switch.SP_AFTER_STAR) | (has_arias << switch_off)), + (R.SP_STAR_CONNECTION, R.SP_STAR_END): has_star & (HasSwitch(Switch.SP_AFTER_STAR) | (has_arias & switch_off)), (R.SP_STAR_END, R.SP_STAR_CONNECTION): has_star & HasSwitch(Switch.SP_AFTER_STAR), } From 7dc86fa7681aa767be96b0c52c976067cb64bd80 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Feb 2026 15:35:37 -0500 Subject: [PATCH 130/135] no longer needed --- worlds/astalon/world.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index dee291009d09..912582e23db7 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -392,10 +392,6 @@ def create_items(self) -> None: self.multiworld.itempool += itempool + filler_items - @override - def set_rules(self) -> None: - self.register_dependencies() - @cached_property def filler_item_names(self) -> tuple[str, ...]: items = list(filler_items) From 2f935cd37214470e024587dd8b2ced630c2d0ea1 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Feb 2026 20:45:09 -0500 Subject: [PATCH 131/135] clean up default rules --- worlds/astalon/logic/custom_rules.py | 3 ++- worlds/astalon/logic/main_campaign.py | 19 +++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index 9af00a4880b1..b2f14b065a2b 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -54,8 +54,9 @@ def __init__( count: int = 1, *, options: Iterable[OptionFilter] = (), + filtered_resolution: bool = False, ) -> None: - super().__init__(as_str(item_name), count, options=options) + super().__init__(as_str(item_name), count, options=options, filtered_resolution=filtered_resolution) @dataclasses.dataclass(init=False) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 1625552b4e56..480591d137b2 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -49,9 +49,9 @@ red_off = [OptionFilter(RandomizeRedKeys, RandomizeRedKeys.option_false)] switch_off = [OptionFilter(RandomizeSwitches, RandomizeSwitches.option_false)] -has_algus = True_(options=characters_off) | Has(Character.ALGUS, options=characters_on) -has_arias = True_(options=characters_off) | Has(Character.ARIAS, options=characters_on) -has_kyuli = True_(options=characters_off) | Has(Character.KYULI, options=characters_on) +has_algus = Has(Character.ALGUS, options=characters_on, filtered_resolution=True) +has_arias = Has(Character.ARIAS, options=characters_on, filtered_resolution=True) +has_kyuli = Has(Character.KYULI, options=characters_on, filtered_resolution=True) has_bram = Has(Character.BRAM) has_zeek = Has(Character.ZEEK) @@ -81,13 +81,8 @@ has_bram_axe = has_bram & Has(ShopUpgrade.BRAM_AXE) has_bram_hunter = has_bram & Has(ShopUpgrade.BRAM_HUNTER) has_bram_whiplash = has_bram & Has(ShopUpgrade.BRAM_WHIPLASH) -chalice_on_easy = HardLogic(True_()) | Has(KeyItem.CHALICE, options=easy) +chalice_on_easy = Has(KeyItem.CHALICE, options=easy) | HardLogic(True_()) -# can_uppies = Macro( -# HardLogic(has_arias | has_bram), -# "Can do uppies", -# "Perform a higher jump by jumping while attacking with Arias or Bram", -# ) can_uppies = HardLogic(has_arias | has_bram) can_extra_height = has_kyuli | has_block | can_uppies can_extra_height_gold_block = has_kyuli | has_zeek | can_uppies @@ -574,7 +569,7 @@ (R.ROA_LOWER_VOID, R.ROA_LOWER_VOID_CONNECTION): HasSwitch(Switch.ROA_LOWER_VOID, otherwise=True), (R.ROA_ARIAS_BABY_GORGON_CONNECTION, R.ROA_ARIAS_BABY_GORGON): ( has_arias - & (HardLogic(True_()) | Has(KeyItem.BELL, options=easy)) + & (Has(KeyItem.BELL, options=easy) | HardLogic(True_())) & (HasSwitch(Crystal.ROA_BABY_GORGON) | otherwise_crystal) ), (R.ROA_ARIAS_BABY_GORGON_CONNECTION, R.ROA_FLAMES_CONNECTION): has_star & Has(KeyItem.BELL), @@ -658,7 +653,7 @@ (R.ROA_DARK_CONNECTION, R.DARK_START): can_extra_height, (R.DARK_START, R.DARK_END): has_claw & HasSwitch(Switch.DARKNESS, otherwise=True), (R.DARK_END, R.ROA_DARK_EXIT): has_claw, - (R.ROA_DARK_EXIT, R.ROA_ABOVE_CENTAUR_R): has_arias & Has(KeyItem.BELL) & (HardLogic(True_()) | has_kyuli), + (R.ROA_DARK_EXIT, R.ROA_ABOVE_CENTAUR_R): has_arias & Has(KeyItem.BELL) & (has_kyuli | HardLogic(True_())), (R.ROA_DARK_EXIT, R.ROA_CRYSTAL_ABOVE_CENTAUR): HardLogic(has_kyuli_ray), (R.ROA_TOP_CENTAUR, R.ROA_DARK_CONNECTION): ( HasSwitch(Switch.ROA_BLOOD_POT, otherwise=True) | HasBlue(BlueDoor.ROA_BLOOD, otherwise=True) @@ -686,7 +681,7 @@ (R.APEX, R.CATA_BOSS): HasElevator(Elevator.CATA_2), (R.APEX, R.HOTP_ELEVATOR): HasElevator(Elevator.HOTP), (R.APEX, R.FINAL_BOSS): ( - HasAll(Eye.RED, Eye.BLUE, Eye.GREEN) & (HardLogic(True_()) | Has(KeyItem.BELL, options=easy)) & HasGoal() + HasAll(Eye.RED, Eye.BLUE, Eye.GREEN) & (Has(KeyItem.BELL, options=easy) | HardLogic(True_())) & HasGoal() ), (R.APEX, R.ROA_APEX_CONNECTION): HasSwitch(Switch.ROA_APEX_ACCESS), (R.APEX, R.TR_START): HasElevator(Elevator.TR), From c0fe76a6e4ce76c7467468f2acb0ee2b615b0ab5 Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Feb 2026 21:27:58 -0500 Subject: [PATCH 132/135] add some missing rules --- worlds/astalon/logic/main_campaign.py | 16 ++++++++++------ worlds/astalon/regions.py | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/worlds/astalon/logic/main_campaign.py b/worlds/astalon/logic/main_campaign.py index 480591d137b2..7581ae0f76ab 100644 --- a/worlds/astalon/logic/main_campaign.py +++ b/worlds/astalon/logic/main_campaign.py @@ -299,6 +299,7 @@ (R.MECH_ZEEK_CONNECTION, R.GT_BOSS): HasElevator(Elevator.GT_2), (R.MECH_ZEEK_CONNECTION, R.MECH_BOSS): HasElevator(Elevator.MECH_2), (R.MECH_SPLIT_PATH, R.MECH_CHAINS): HasSwitch(Switch.MECH_SPLIT_PATH), + (R.MECH_SPLIT_PATH, R.MECH_RIGHT): HasSwitch(Switch.MECH_SKULL_PUZZLE, otherwise=True), (R.MECH_RIGHT, R.MECH_TRIPLE_SWITCHES): HardLogic( HasSwitch( Switch.MECH_SPLIT_PATH, @@ -313,7 +314,7 @@ (R.MECH_RIGHT, R.MECH_OLD_MAN): ( HasSwitch(Crystal.MECH_OLD_MAN) | otherwise_crystal | (has_kyuli & has_block & Has(KeyItem.BELL)) ), - (R.MECH_RIGHT, R.MECH_SPLIT_PATH): has_star, + (R.MECH_RIGHT, R.MECH_SPLIT_PATH): has_star | HasSwitch(Switch.MECH_SKULL_PUZZLE), (R.MECH_RIGHT, R.MECH_BELOW_POTS): ( HasWhite(WhiteDoor.MECH_ARENA, otherwise=True) | HasSwitch(Switch.MECH_EYEBALL) ), @@ -409,7 +410,8 @@ | (has_star & HasSwitch(Switch.HOTP_LEFT_1, Switch.HOTP_LEFT_2, otherwise=True)) ), (R.HOTP_START_MID, R.HOTP_START_BOTTOM_MID): HasSwitch(Switch.HOTP_GHOSTS, otherwise=True), - (R.HOTP_START_MID, R.HOTP_LOWER_VOID): HardLogic(has_algus | has_bram_whiplash), + (R.HOTP_START_MID, R.HOTP_LOWER_VOID_CONNECTION): HardLogic(has_algus | has_bram_whiplash), + (R.HOTP_LOWER_VOID_CONNECTION, R.HOTP_LOWER_VOID): has_claw, (R.HOTP_LOWER_VOID, R.HOTP_UPPER_VOID): has_void, (R.HOTP_START_LEFT, R.HOTP_ELEVATOR): HasSwitch(Switch.HOTP_LEFT_BACKTRACK), (R.HOTP_START_LEFT, R.HOTP_START_MID): ( @@ -438,6 +440,8 @@ (R.HOTP_AMULET_CONNECTION, R.HOTP_AMULET): has_claw & HasAll(Eye.RED, Eye.BLUE), (R.HOTP_AMULET_CONNECTION, R.GT_BUTT): HasSwitch(Switch.HOTP_ROCK, otherwise=True), (R.HOTP_AMULET_CONNECTION, R.HOTP_MECH_VOID_CONNECTION): HasSwitch(Crystal.HOTP_ROCK_ACCESS) | otherwise_crystal, + (R.HOTP_TP_TUTORIAL, R.HOTP_BELL_CAMPFIRE): HasSwitch(Switch.HOTP_SKULL_PUZZLE, otherwise=True), + (R.HOTP_BELL_CAMPFIRE, R.HOTP_TP_TUTORIAL): HasSwitch(Switch.HOTP_SKULL_PUZZLE), (R.HOTP_BELL_CAMPFIRE, R.HOTP_LOWER_ARIAS): has_arias & (Has(KeyItem.BELL) | can_uppies), (R.HOTP_BELL_CAMPFIRE, R.HOTP_RED_KEY): Has(Eye.GREEN) & has_cloak, (R.HOTP_BELL_CAMPFIRE, R.HOTP_CATH_CONNECTION): Has(Eye.GREEN), @@ -660,16 +664,16 @@ ), (R.ROA_TOP_CENTAUR, R.ROA_DARK_EXIT): can_extra_height, (R.ROA_TOP_CENTAUR, R.ROA_BOSS_CONNECTION): ( - HasSwitch(Crystal.ROA_CENTAUR) | CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR) + HasSwitch(Crystal.ROA_CENTAUR) | CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR, options=switch_off) ), (R.ROA_ABOVE_CENTAUR_R, R.ROA_DARK_EXIT): has_arias & Has(KeyItem.BELL), (R.ROA_ABOVE_CENTAUR_R, R.ROA_ABOVE_CENTAUR_L): has_star & Has(KeyItem.BELL), - (R.ROA_ABOVE_CENTAUR_R, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_whiplash, + (R.ROA_ABOVE_CENTAUR_R, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_whiplash & Has(KeyItem.BELL), (R.ROA_ABOVE_CENTAUR_L, R.ROA_ABOVE_CENTAUR_R): has_star & Has(KeyItem.BELL), (R.ROA_ABOVE_CENTAUR_L, R.ROA_CRYSTAL_ABOVE_CENTAUR): can_crystal_no_block, (R.ROA_BOSS_CONNECTION, R.ROA_ABOVE_CENTAUR_L): can_extra_height, (R.ROA_BOSS_CONNECTION, R.ROA_TOP_CENTAUR): ( - HasSwitch(Crystal.ROA_CENTAUR) | CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR) + HasSwitch(Crystal.ROA_CENTAUR) | CanReachRegion(R.ROA_CRYSTAL_ABOVE_CENTAUR, options=switch_off) ), (R.ROA_BOSS_CONNECTION, R.ROA_BOSS): HasSwitch(Switch.ROA_BOSS_ACCESS, otherwise=True), (R.ROA_BOSS, R.ROA_APEX_CONNECTION): Has(Eye.GREEN), @@ -772,7 +776,7 @@ (R.CATA_DOUBLE_DOOR, R.CATA_VOID_R): ( Has(KeyItem.BELL) & can_kill_ghosts & (HasSwitch(Face.CATA_DOUBLE_DOOR) | otherwise_bow) ), - (R.CATA_VOID_R, R.CATA_DOUBLE_DOOR): HardLogic(HasAll(KeyItem.BELL, ShopUpgrade.ALGUS_METEOR)), + (R.CATA_VOID_R, R.CATA_DOUBLE_DOOR): HardLogic(Has(KeyItem.BELL) & has_algus_meteor), (R.CATA_VOID_R, R.CATA_VOID_L): has_void, (R.CATA_VOID_L, R.CATA_VOID_R): has_void, (R.CATA_VOID_L, R.CATA_BOSS): HasWhite(WhiteDoor.CATA_PRISON, otherwise=True) & has_kyuli, diff --git a/worlds/astalon/regions.py b/worlds/astalon/regions.py index 5d8fd03bdf0e..fa458ebcf89e 100644 --- a/worlds/astalon/regions.py +++ b/worlds/astalon/regions.py @@ -803,6 +803,7 @@ class RegionData: ), RegionName.HOTP_BELL_CAMPFIRE: RegionData( exits=( + RegionName.HOTP_TP_TUTORIAL, RegionName.HOTP_RED_KEY, RegionName.HOTP_BELL, RegionName.HOTP_CATH_CONNECTION, From 03774ee8ad6a234bc10b87f2b6f0a5ab673bffbe Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Feb 2026 21:34:24 -0500 Subject: [PATCH 133/135] forgot the most important change --- worlds/astalon/world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worlds/astalon/world.py b/worlds/astalon/world.py index 912582e23db7..4c8b2405bad9 100644 --- a/worlds/astalon/world.py +++ b/worlds/astalon/world.py @@ -51,7 +51,7 @@ # ░▓████▓▓░█████████░██░ MANY GOOD PROGRAMS AND FEW ERRORS WILL COME TO YOU # █░▓██▓▓░███░███░██░▓░█ AS LONG AS YOU KEEP HER IN YOUR PROGRAM TO WATCH OVER IT # ██░░▓▓▓░███░███░██░░██ INCREMENT THIS NUMBER EVERY TIME YOU SAY HI TO BUBSETTE -# ████░░░░██████████░███ hi_bubsette = 3 +# ████░░░░██████████░███ hi_bubsette = 4 # ████████░░░░░░░░░░████ From 78dfd2dd975f36f5c8a4c6d891b8b2b2e0c9564d Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Feb 2026 22:02:16 -0500 Subject: [PATCH 134/135] do not use cached world I guess --- worlds/astalon/bases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/astalon/bases.py b/worlds/astalon/bases.py index 8c620543d3bc..1a5c9383548f 100644 --- a/worlds/astalon/bases.py +++ b/worlds/astalon/bases.py @@ -1,14 +1,14 @@ from typing import ClassVar from BaseClasses import MultiWorld -from rule_builder.cached_world import CachedRuleBuilderWorld +from worlds.AutoWorld import World from .items import Character, EarlyItems from .options import AstalonOptions from .settings import AstalonSettings -class AstalonWorldBase(CachedRuleBuilderWorld): +class AstalonWorldBase(World): options_dataclass = AstalonOptions settings: ClassVar[AstalonSettings] # pyright: ignore[reportIncompatibleVariableOverride] From 4b7b5fe01bdfe8aee8e399f8ea309e2fd1ed6d4b Mon Sep 17 00:00:00 2001 From: Ian Robinson Date: Sun, 8 Feb 2026 22:18:32 -0500 Subject: [PATCH 135/135] fix removing caching --- worlds/astalon/logic/custom_rules.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/worlds/astalon/logic/custom_rules.py b/worlds/astalon/logic/custom_rules.py index b2f14b065a2b..f48951b5dd3a 100644 --- a/worlds/astalon/logic/custom_rules.py +++ b/worlds/astalon/logic/custom_rules.py @@ -223,12 +223,7 @@ class HasGoal(rules.Rule[AstalonWorldBase], game=GAME_NAME): def _instantiate(self, world: AstalonWorldBase) -> rules.Rule.Resolved: if world.options.goal.value != Goal.option_eye_hunt: return rules.True_().resolve(world) - return Has.Resolved( - Eye.GOLD.value, - count=world.options.additional_eyes_required.value, - player=world.player, - caching_enabled=world.rule_caching_enabled, - ) + return Has(Eye.GOLD, world.options.additional_eyes_required.value).resolve(world) @dataclasses.dataclass() @@ -241,7 +236,7 @@ def _instantiate(self, world: AstalonWorldBase) -> rules.Rule.Resolved: return self.Resolved( self.child.resolve(world), player=world.player, - caching_enabled=world.rule_caching_enabled, + caching_enabled=getattr(world, "rule_caching_enabled", False), ) return rules.False_().resolve(world) @@ -272,7 +267,11 @@ class CampfireWarp(rules.True_[AstalonWorldBase], game=GAME_NAME): @override def _instantiate(self, world: AstalonWorldBase) -> "Resolved": - return self.Resolved(self.name, player=world.player) + return self.Resolved( + self.name, + player=world.player, + caching_enabled=getattr(world, "rule_caching_enabled", False), + ) class Resolved(rules.True_.Resolved): name: str