From c5a564bfc9bcf422d04c9016f56d65260b007c67 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Sat, 13 Dec 2025 07:10:05 -0500 Subject: Refactor AccessRequirements --- apworld/rules.py | 243 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 196 insertions(+), 47 deletions(-) (limited to 'apworld/rules.py') diff --git a/apworld/rules.py b/apworld/rules.py index f859e75..70a76c0 100644 --- a/apworld/rules.py +++ b/apworld/rules.py @@ -1,66 +1,215 @@ -from collections.abc import Callable from typing import TYPE_CHECKING -from BaseClasses import CollectionState, Region -from .player_logic import AccessRequirements +from BaseClasses import CollectionState if TYPE_CHECKING: from . import Lingo2World -def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region], - world: "Lingo2World") -> bool: - if not all(state.has(item, world.player) for item in reqs.items): - return False +class AccessRequirements: + items: set[str] + progressives: dict[str, int] + rooms: set[str] + letters: dict[str, int] + cyans: bool - if not all(state.has(item, world.player, amount) for item, amount in reqs.progressives.items()): - return False + # This is an AND of ORs. + or_logic: list[list["AccessRequirements"]] - if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): - return False + # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should + # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1. + complete_at: int | None + possibilities: list["AccessRequirements"] - if not all(state.can_reach(region) for region in regions): - return False + def __init__(self): + self.items = set() + self.progressives = dict() + self.rooms = set() + self.letters = dict() + self.cyans = False + self.or_logic = list() + self.complete_at = None + self.possibilities = list() - for letter_key, letter_level in reqs.letters.items(): - if not state.has(letter_key, world.player, letter_level): + def copy(self) -> "AccessRequirements": + reqs = AccessRequirements() + reqs.items = self.items.copy() + reqs.progressives = self.progressives.copy() + reqs.rooms = self.rooms.copy() + reqs.letters = self.letters.copy() + reqs.cyans = self.cyans + reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic] + reqs.complete_at = self.complete_at + reqs.possibilities = self.possibilities.copy() + return reqs + + def merge(self, other: "AccessRequirements"): + for item in other.items: + self.items.add(item) + + for item, amount in other.progressives.items(): + self.progressives[item] = max(amount, self.progressives.get(item, 0)) + + for room in other.rooms: + self.rooms.add(room) + + for letter, level in other.letters.items(): + self.letters[letter] = max(self.letters.get(letter, 0), level) + + self.cyans = self.cyans or other.cyans + + for disjunction in other.or_logic: + self.or_logic.append([sub_req.copy() for sub_req in disjunction]) + + if other.complete_at is not None: + # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of + # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports + # conjunctions of requirements. + if self.complete_at is not None: + print("Merging requirements with complete_at > 1. This is messy and should be avoided!") + + left_req = AccessRequirements() + left_req.complete_at = self.complete_at + left_req.possibilities = [sub_req.copy() for sub_req in self.possibilities] + self.or_logic.append([left_req]) + + self.complete_at = None + self.possibilities = list() + + right_req = AccessRequirements() + right_req.complete_at = other.complete_at + right_req.possibilities = [sub_req.copy() for sub_req in other.possibilities] + self.or_logic.append([right_req]) + else: + self.complete_at = other.complete_at + self.possibilities = [sub_req.copy() for sub_req in other.possibilities] + + def is_empty(self) -> bool: + return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0 + and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None) + + def __eq__(self, other: "AccessRequirements"): + return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and + self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and + self.complete_at == other.complete_at and self.possibilities == other.possibilities) + + def simplify(self): + resimplify = False + + if len(self.or_logic) > 0: + old_or_logic = self.or_logic + + def remove_redundant(sub_reqs: "AccessRequirements"): + new_reqs = sub_reqs.copy() + new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v} + if new_reqs != sub_reqs: + return new_reqs + else: + return sub_reqs + + self.or_logic = [] + for disjunction in old_or_logic: + new_disjunction = [] + for ssr in disjunction: + new_ssr = remove_redundant(ssr) + if not new_ssr.is_empty(): + new_disjunction.append(new_ssr) + else: + new_disjunction.clear() + break + if len(new_disjunction) == 1: + self.merge(new_disjunction[0]) + resimplify = True + elif len(new_disjunction) > 1: + if all(cjr == new_disjunction[0] for cjr in new_disjunction): + self.merge(new_disjunction[0]) + resimplify = True + else: + self.or_logic.append(new_disjunction) + + if resimplify: + self.simplify() + + def get_referenced_rooms(self): + result = set(self.rooms) + + for disjunction in self.or_logic: + for sub_req in disjunction: + result = result.union(sub_req.get_referenced_rooms()) + + for sub_req in self.possibilities: + result = result.union(sub_req.get_referenced_rooms()) + + return result + + def remove_room(self, room: str): + if room in self.rooms: + self.rooms.remove(room) + + for disjunction in self.or_logic: + for sub_req in disjunction: + sub_req.remove_room(room) + + for sub_req in self.possibilities: + sub_req.remove_room(room) + + def __repr__(self): + parts = [] + if len(self.items) > 0: + parts.append(f"items={self.items}") + if len(self.progressives) > 0: + parts.append(f"progressives={self.progressives}") + if len(self.rooms) > 0: + parts.append(f"rooms={self.rooms}") + if len(self.letters) > 0: + parts.append(f"letters={self.letters}") + if self.cyans: + parts.append(f"cyans=True") + if len(self.or_logic) > 0: + parts.append(f"or_logic={self.or_logic}") + if self.complete_at is not None: + parts.append(f"complete_at={self.complete_at}") + if len(self.possibilities) > 0: + parts.append(f"possibilities={self.possibilities}") + return "AccessRequirements(" + ", ".join(parts) + ")" + + def check_access(self, state: CollectionState, world: "Lingo2World") -> bool: + if not all(state.has(item, world.player) for item in self.items): return False - if reqs.cyans: - if not any(state.has(letter, world.player, amount) - for letter, amount in world.player_logic.double_letter_amount.items()): + if not all(state.has(item, world.player, amount) for item, amount in self.progressives.items()): return False - if len(reqs.or_logic) > 0: - if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction) - for subjunction in reqs.or_logic): + if not all(state.can_reach_region(region_name, world.player) for region_name in self.rooms): return False - if reqs.complete_at is not None: - completed = 0 - checked = 0 - for possibility in reqs.possibilities: - checked += 1 - if lingo2_can_satisfy_requirements(state, possibility, [], world): - completed += 1 - if completed >= reqs.complete_at: - break - elif len(reqs.possibilities) - checked + completed < reqs.complete_at: - # There aren't enough remaining possibilities for the check to pass. + for letter_key, letter_level in self.letters.items(): + if not state.has(letter_key, world.player, letter_level): + return False + + if self.cyans: + if not any(state.has(letter, world.player, amount) + for letter, amount in world.player_logic.double_letter_amount.items()): + return False + + if len(self.or_logic) > 0: + if not all(any(sub_reqs.check_access(state, world) for sub_reqs in subjunction) + for subjunction in self.or_logic): + return False + + if self.complete_at is not None: + completed = 0 + checked = 0 + for possibility in self.possibilities: + checked += 1 + if possibility.check_access(state, world): + completed += 1 + if completed >= self.complete_at: + break + elif len(self.possibilities) - checked + completed < self.complete_at: + # There aren't enough remaining possibilities for the check to pass. + return False + if completed < self.complete_at: return False - if completed < reqs.complete_at: - return False - return True - -def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World", - regions: dict[str, Region] | None) -> Callable[[CollectionState], bool]: - # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule - # checking. - if regions is not None: - required_regions = [regions[room_name] for room_name in reqs.rooms] - else: - required_regions = [world.multiworld.get_region(room_name, world.player) for room_name in reqs.rooms] - new_reqs = reqs.copy() - new_reqs.rooms.clear() - return lambda state: lingo2_can_satisfy_requirements(state, new_reqs, required_regions, world) + return True -- cgit 1.4.1