import dataclasses from collections.abc import Callable, Iterable, Mapping from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Never, Self, cast from typing_extensions import TypeVar, dataclass_transform, override from BaseClasses import CollectionState from NetUtils import JSONMessagePart from .options import OptionFilter if TYPE_CHECKING: from worlds.AutoWorld import World TWorld = TypeVar("TWorld", bound=World, contravariant=True, default=World) # noqa: PLC0105 else: TWorld = TypeVar("TWorld") def _create_hash_fn(resolved_rule_cls: "CustomRuleRegister") -> Callable[..., int]: def hash_impl(self: "Rule.Resolved") -> int: return hash( ( self.__class__.__module__, self.rule_name, *[getattr(self, f.name) for f in dataclasses.fields(self)], ) ) hash_impl.__qualname__ = f"{resolved_rule_cls.__qualname__}.__hash__" return hash_impl @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""" 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 to hold custom rules implemented by worlds""" rule_name: str = "Rule" """The string name of a rule, must be unique per game""" def __new__( cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any], /, **kwds: dict[str, Any], ) -> 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) @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""" 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]): """Base class for a static rule used to generate an access rule""" 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'""" def __post_init__(self) -> None: if not isinstance(self.options, tuple): self.options = tuple(self.options) def _instantiate(self, world: TWorld) -> "Resolved": """Create a new resolved rule for this world""" 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""" for option_filter in self.options: if not option_filter.check(world.options): return True_().resolve(world) if self.filtered_resolution else False_().resolve(world) return self._instantiate(world) 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 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, } @classmethod 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, 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""" 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): return And(*self.children, *other.children, options=self.options) return And(*self.children, other, options=self.options) if isinstance(other, And): return And(self, *other.children, options=other.options) return And(self, other) def __rand__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": return self.__and__(other) 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): return Or(*self.children, *other.children, options=self.options) return Or(*self.children, other, options=self.options) if isinstance(other, Or): return Or(self, *other.children, options=self.options) return Or(self, other) def __ror__(self, other: "Rule[Any] | Iterable[OptionFilter] | OptionFilter") -> "Rule[TWorld]": return self.__or__(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 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 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 elif cls.__module__ != "rule_builder.rules": 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""" _: dataclasses.KW_ONLY player: int """The player this rule is for""" 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 """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""" 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""" if not self.caching_enabled: return self._evaluate(state) 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) player_results[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""" 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 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}] 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 self.rule_name @dataclasses.dataclass() class True_(Rule[TWorld], game="Archipelago"): # noqa: N801 """A rule that always returns True""" class Resolved(Rule.Resolved): always_true: ClassVar[bool] = True skip_cache: ClassVar[bool] = True @override def _evaluate(self, state: CollectionState) -> bool: return True @override 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], game="Archipelago"): # noqa: N801 """A rule that always returns False""" class Resolved(Rule.Resolved): always_false: ClassVar[bool] = True skip_cache: ClassVar[bool] = True @override def _evaluate(self, state: CollectionState) -> bool: return False @override 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], game="Archipelago"): """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""" def __init__( self, *children: Rule[TWorld], options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: super().__init__(options=options, filtered_resolution=filtered_resolution) self.children = children @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=getattr(world, "rule_caching_enabled", False), ) @override def to_dict(self) -> dict[str, Any]: data = super().to_dict() del data["args"] data["children"] = [c.to_dict() for c in self.children] return data @override @classmethod 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, filtered_resolution=data.get("filtered_resolution", False)) @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})" 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]] = {} 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 @override 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 @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 @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"): """A rule that only returns true when all child rules evaluate as true""" @override def _instantiate(self, world: TWorld) -> Rule.Resolved: 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 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 False_().resolve(world) 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=getattr(world, "rule_caching_enabled", False), ) class Resolved(NestedRule.Resolved): @override 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]: 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_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})" @dataclasses.dataclass(init=False) 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 = [c.resolve(world) 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 False_().resolve(world) 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=getattr(world, "rule_caching_enabled", False), ) class Resolved(NestedRule.Resolved): @override 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]: 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_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})" @dataclasses.dataclass() 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""" @override def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( self.child.resolve(world), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def to_dict(self) -> dict[str, Any]: data = super().to_dict() del data["args"] data["child"] = self.child.to_dict() return data @override @classmethod 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") options = OptionFilter.multiple_from_dict(data.get("options", ())) return cls( world_cls.rule_from_dict(child), options=options, filtered_resolution=data.get("filtered_resolution", False), ) @override def __str__(self) -> str: return f"{self.__class__.__name__}[{self.child}]" 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) @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 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 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 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} ["}] 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.rule_name}[{self.child.explain_str(state)}]" @override def __str__(self) -> str: return f"{self.rule_name}[{self.child}]" @dataclasses.dataclass() class Filtered(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 self.child.resolve(world) @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) -> Rule.Resolved: return self.Resolved( self.item_name, self.count, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @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})" class Resolved(Rule.Resolved): item_name: str count: int = 1 skip_cache: ClassVar[bool] = True @override def _evaluate(self, state: CollectionState) -> bool: # 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]]: return {self.item_name: set()} @override 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: messages.append({"type": "color", "color": "cyan", "text": str(self.count)}) messages.append({"type": "text", "text": "x "}) if state: 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 def explain_str(self, state: CollectionState | None = None) -> str: if state is None: return str(self) prefix = "Has" if self(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], game="Archipelago"): """A rule that checks if the player has all of the given items""" item_names: tuple[str, ...] """A tuple of item names to check for""" def __init__( self, *item_names: str, options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: super().__init__(options=options, filtered_resolution=filtered_resolution) self.item_names = tuple(sorted(set(item_names))) @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) return self.Resolved( self.item_names, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @classmethod 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", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @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})" class Resolved(Rule.Resolved): item_names: tuple[str, ...] @override def _evaluate(self, state: CollectionState) -> bool: # 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]]: 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": "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 ("}, ] 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": "color", "color": "green", "text": item}) 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": "color", "color": "salmon", "text": item}) 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(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 "" return f"{prefix} of ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: items = ", ".join(self.item_names) return f"Has all of ({items})" @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""" item_names: tuple[str, ...] """A tuple of item names to check for""" def __init__( self, *item_names: str, options: Iterable[OptionFilter] = (), filtered_resolution: bool = False, ) -> None: super().__init__(options=options, filtered_resolution=filtered_resolution) self.item_names = tuple(sorted(set(item_names))) @override def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # 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, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @classmethod 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", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @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})" class Resolved(Rule.Resolved): item_names: tuple[str, ...] @override def _evaluate(self, state: CollectionState) -> bool: # 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]]: 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": "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 ("}, ] 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": "color", "color": "green", "text": item}) 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": "color", "color": "salmon", "text": item}) 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(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 "" return f"{prefix} of ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: items = ", ".join(self.item_names) return f"Has any 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: # match state.has_all_counts 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( tuple(self.item_counts.items()), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @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_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]]: 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): 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 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"}, {"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": "color", "color": "green", "text": item}) 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": "color", "color": "salmon", "text": item}) 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 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(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]) 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: # 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) return self.Resolved( tuple(self.item_counts.items()), player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @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 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 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): 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 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"}, {"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": "color", "color": "green", "text": item}) 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": "color", "color": "salmon", "text": item}) 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 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(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]) return f"Has any of ({items})" @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""" 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] = (), filtered_resolution: bool = False, ) -> None: super().__init__(options=options, filtered_resolution=filtered_resolution) self.item_names = tuple(sorted(set(item_names))) self.count = count @override def _instantiate(self, world: TWorld) -> Rule.Resolved: if len(self.item_names) == 0: # match state.has_from_list return False_().resolve(world) 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, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @classmethod 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", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @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 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]]: 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": "x 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(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": color, "text": f"{found_count}/{self.count}", }, {"type": "text", "text": " 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": "color", "color": "green", "text": item}) 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": "color", "color": "salmon", "text": item}) 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(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} items from ({found_str}{infix}{missing_str})" @override def __str__(self) -> str: items = ", ".join(self.item_names) count = f"{self.count}x items" if self.count > 1 else "an item" return f"Has {count} from ({items})" @dataclasses.dataclass(init=False) 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] = (), filtered_resolution: bool = False, ) -> None: super().__init__(options=options, filtered_resolution=filtered_resolution) self.item_names = tuple(sorted(set(item_names))) self.count = 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, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override @classmethod 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", ())) return cls(*item_names, **args, options=options, filtered_resolution=data.get("filtered_resolution", False)) @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 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 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": "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] color = "green" if found_count >= self.count else "salmon" messages = [ {"type": "text", "text": "Has "}, {"type": "color", "color": color, "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": "color", "color": "green", "text": item}) 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": "color", "color": "salmon", "text": item}) 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"): """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) -> 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=getattr(world, "rule_caching_enabled", False), ) @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 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]]: 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(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""" 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=getattr(world, "rule_caching_enabled", False), ) @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 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 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_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"): """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""" 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: 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) 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, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.location_name}{options})" class Resolved(Rule.Resolved): location_name: str parent_region_name: str @override def _evaluate(self, state: CollectionState) -> bool: return state.can_reach_location(self.location_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 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: verb = "Can reach" elif self(state): verb = "Reached" else: verb = "Cannot reach" return [ {"type": "text", "text": f"{verb} 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(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], 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""" @override def _instantiate(self, world: TWorld) -> Rule.Resolved: return self.Resolved( self.region_name, player=world.player, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.region_name}{options})" class Resolved(Rule.Resolved): region_name: str @override def _evaluate(self, state: CollectionState) -> bool: return state.can_reach_region(self.region_name, self.player) @override 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]: if state is None: verb = "Can reach" elif self(state): verb = "Reached" else: verb = "Cannot reach" return [ {"type": "text", "text": f"{verb} 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(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], 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""" 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) -> Rule.Resolved: 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, caching_enabled=getattr(world, "rule_caching_enabled", False), ) @override def __str__(self) -> str: options = f", options={self.options}" if self.options else "" return f"{self.__class__.__name__}({self.entrance_name}{options})" class Resolved(Rule.Resolved): entrance_name: str 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 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: verb = "Can reach" elif self(state): verb = "Reached" else: verb = "Cannot reach" return [ {"type": "text", "text": f"{verb} 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(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: "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 }