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/locations.py | 78 +++++++++++++++- apworld/player_logic.py | 169 +-------------------------------- apworld/regions.py | 42 ++++----- apworld/rules.py | 243 ++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 294 insertions(+), 238 deletions(-) diff --git a/apworld/locations.py b/apworld/locations.py index 3d619dc..fc7ef26 100644 --- a/apworld/locations.py +++ b/apworld/locations.py @@ -1,4 +1,20 @@ -from BaseClasses import Location +from typing import TYPE_CHECKING + +from BaseClasses import Location, Region, CollectionState, Entrance +from .rules import AccessRequirements + +if TYPE_CHECKING: + from . import Lingo2World + + +def get_required_regions(reqs: AccessRequirements, world: "Lingo2World", + regions: dict[str, Region] | None) -> list[Region]: + # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule + # checking. + if regions is not None: + return [regions[room_name] for room_name in reqs.rooms] + else: + return [world.multiworld.get_region(room_name, world.player) for room_name in reqs.rooms] class Lingo2Location(Location): @@ -6,3 +22,63 @@ class Lingo2Location(Location): port_id: int goal: bool + reqs: AccessRequirements | None + world: "Lingo2World" + required_regions: list[Region] + + @classmethod + def non_event_location(cls, world: "Lingo2World", code: int, region: Region): + result = cls(world.player, world.static_logic.location_id_to_name[code], code, region) + result.reqs = None + result.world = world + result.required_regions = [] + + return result + + @classmethod + def event_location(cls, world: "Lingo2World", name: str, region: Region): + result = cls(world.player, name, None, region) + result.reqs = None + result.world = world + result.required_regions = [] + + return result + + def set_access_rule(self, reqs: AccessRequirements, regions: dict[str, Region] | None): + self.reqs = reqs + self.required_regions = get_required_regions(reqs, self.world, regions) + + def access_rule(self, state: CollectionState) -> bool: + if self.reqs is not None and not self.reqs.check_access(state, self.world): + return False + + if not all(state.can_reach(region) for region in self.required_regions): + return False + + return True + + +class Lingo2Entrance(Entrance): + reqs: AccessRequirements | None + world: "Lingo2World" + required_regions: list[Region] + + def __init__(self, world: "Lingo2World", description: str, region: Region): + super().__init__(world.player, description, region) + + self.reqs = None + self.world = world + self.required_regions = [] + + def set_access_rule(self, reqs: AccessRequirements, regions: dict[str, Region] | None): + self.reqs = reqs + self.required_regions = get_required_regions(reqs, self.world, regions) + + def access_rule(self, state: CollectionState) -> bool: + if self.reqs is not None and not self.reqs.check_access(state, self.world): + return False + + if not all(state.can_reach(region) for region in self.required_regions): + return False + + return True diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 3ee8f38..892dac5 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py @@ -5,6 +5,7 @@ from .items import SYMBOL_ITEMS from typing import TYPE_CHECKING, NamedTuple from .options import ShuffleLetters, CyanDoorBehavior +from .rules import AccessRequirements if TYPE_CHECKING: from . import Lingo2World @@ -20,174 +21,6 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]: return histogram -class AccessRequirements: - items: set[str] - progressives: dict[str, int] - rooms: set[str] - letters: dict[str, int] - cyans: bool - - # This is an AND of ORs. - or_logic: list[list["AccessRequirements"]] - - # 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"] - - 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() - - 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) + ")" - - class PlayerLocation(NamedTuple): code: int | None reqs: AccessRequirements diff --git a/apworld/regions.py b/apworld/regions.py index 1118603..d5bdd46 100644 --- a/apworld/regions.py +++ b/apworld/regions.py @@ -1,12 +1,10 @@ from typing import TYPE_CHECKING -import BaseClasses -from BaseClasses import Region, ItemClassification, Entrance +from BaseClasses import Region, ItemClassification, Entrance, EntranceType from entrance_rando import randomize_entrances from .items import Lingo2Item -from .locations import Lingo2Location -from .player_logic import AccessRequirements -from .rules import make_location_lambda +from .locations import Lingo2Location, Lingo2Entrance +from .rules import AccessRequirements if TYPE_CHECKING: from . import Lingo2World @@ -21,13 +19,13 @@ def create_locations(room, new_region: Region, world: "Lingo2World", regions: di reqs = location.reqs.copy() reqs.remove_room(new_region.name) - new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], - location.code, new_region) - new_location.access_rule = make_location_lambda(reqs, world, regions) + new_location = Lingo2Location.non_event_location(world, location.code, new_region) + new_location.set_access_rule(reqs, regions) new_region.locations.append(new_location) for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): - new_location = Lingo2Location(world.player, event_name, None, new_region) + new_location = Lingo2Location.event_location(world, event_name, new_region) + if world.for_tracker and item_name == "Victory": new_location.goal = True @@ -41,12 +39,11 @@ def create_locations(room, new_region: Region, world: "Lingo2World", regions: di if port.no_shuffle: continue - new_location = Lingo2Location(world.player, f"Worldport {port.id} Entered", None, new_region) + new_location = Lingo2Location.event_location(world, f"Worldport {port.id} Entered", new_region) new_location.port_id = port.id if port.HasField("required_door"): - new_location.access_rule = \ - make_location_lambda(world.player_logic.get_door_open_reqs(port.required_door), world, regions) + new_location.set_access_rule(world.player_logic.get_door_open_reqs(port.required_door), regions) new_region.locations.append(new_location) @@ -141,8 +138,8 @@ def create_regions(world: "Lingo2World"): # what regions are dead ends. continue - connection = Entrance(world.player, connection_name, regions[from_region]) - connection.access_rule = make_location_lambda(reqs, world, regions) + connection = Lingo2Entrance(world, connection_name, regions[from_region]) + connection.set_access_rule(reqs, regions) regions[from_region].exits.append(connection) connection.connect(regions[to_region]) @@ -182,13 +179,13 @@ def shuffle_entrances(world: "Lingo2World"): from_region = world.multiworld.get_region(from_region_name, world.player) to_region = world.multiworld.get_region(to_region_name, world.player) - connection = Entrance(world.player, f"{from_region_name} - {chosen_port.display_name}", from_region) + connection = Lingo2Entrance(world, f"{from_region_name} - {chosen_port.display_name}", from_region) from_region.exits.append(connection) connection.connect(to_region) if chosen_port.HasField("required_door"): door_reqs = world.player_logic.get_door_open_reqs(chosen_port.required_door) - connection.access_rule = make_location_lambda(door_reqs, world, None) + connection.set_access_rule(door_reqs, None) for region in door_reqs.get_referenced_rooms(): world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), @@ -202,14 +199,15 @@ def shuffle_entrances(world: "Lingo2World"): port_id_by_name[connection_name] = port.id entrance = port_region.create_er_target(connection_name) - entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY + entrance.randomization_type = EntranceType.TWO_WAY - er_exit = port_region.create_exit(connection_name) - er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY + er_exit = Lingo2Entrance(world, connection_name, port_region) + port_region.exits.append(er_exit) + er_exit.randomization_type = EntranceType.TWO_WAY if port.HasField("required_door"): door_reqs = world.player_logic.get_door_open_reqs(port.required_door) - er_exit.access_rule = make_location_lambda(door_reqs, world, None) + er_exit.set_access_rule(door_reqs, None) for region in door_reqs.get_referenced_rooms(): world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), @@ -236,7 +234,7 @@ def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"): from_region = world.multiworld.get_region(from_region_name, world.player) to_region = world.multiworld.get_region(to_region_name, world.player) - connection = Entrance(world.player, f"{from_region_name} - {from_port.display_name}", from_region) + connection = Lingo2Entrance(world, f"{from_region_name} - {from_port.display_name}", from_region) reqs = AccessRequirements() if from_port.HasField("required_door"): @@ -246,7 +244,7 @@ def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"): reqs.items.add(f"Worldport {fpid} Entered") if not reqs.is_empty(): - connection.access_rule = make_location_lambda(reqs, world, None) + connection.set_access_rule(reqs, None) for region in reqs.get_referenced_rooms(): world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), 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