from enum import IntEnum, auto from .generated import data_pb2 as data_pb2 from .items import SYMBOL_ITEMS from typing import TYPE_CHECKING, NamedTuple from .options import VictoryCondition, ShuffleLetters, CyanDoorBehavior if TYPE_CHECKING: from . import Lingo2World def calculate_letter_histogram(solution: str) -> dict[str, int]: histogram = dict() for l in solution: if l.isalpha(): real_l = l.upper() histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2) 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(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 = 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 = other.possibilities self.or_logic.append([right_req]) else: self.complete_at = other.complete_at self.possibilities = 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"): sub_reqs.letters = {l: v for l, v in sub_reqs.letters.items() if self.letters.get(l, 0) < v} self.or_logic = [] for disjunction in old_or_logic: new_disjunction = [] for ssr in disjunction: remove_redundant(ssr) if not ssr.is_empty(): new_disjunction.append(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 __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 f"AccessRequirements({", ".join(parts)})" class PlayerLocation(NamedTuple): code: int | None reqs: AccessRequirements class LetterBehavior(IntEnum): VANILLA = auto() ITEM = auto() UNLOCKED = auto() class Lingo2PlayerLogic: world: "Lingo2World" locations_by_room: dict[int, list[PlayerLocation]] event_loc_item_by_room: dict[int, dict[str, str]] item_by_door: dict[int, tuple[str, int]] panel_reqs: dict[int, AccessRequirements] proxy_reqs: dict[int, dict[str, AccessRequirements]] door_reqs: dict[int, AccessRequirements] real_items: list[str] double_letter_amount: dict[str, int] def __init__(self, world: "Lingo2World"): self.world = world self.locations_by_room = {} self.event_loc_item_by_room = {} self.item_by_door = {} self.panel_reqs = dict() self.proxy_reqs = dict() self.door_reqs = dict() self.real_items = list() self.double_letter_amount = dict() if self.world.options.shuffle_doors: for progressive in world.static_logic.objects.progressives: for i in range(0, len(progressive.doors)): self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1) self.real_items.append(progressive.name) for door_group in world.static_logic.objects.door_groups: if door_group.type == data_pb2.DoorGroupType.CONNECTOR: if not self.world.options.shuffle_doors: continue elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR: if not self.world.options.shuffle_control_center_colors: continue elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP: if not self.world.options.shuffle_doors: continue else: continue for door in door_group.doors: self.item_by_door[door] = (door_group.name, 1) self.real_items.append(door_group.name) # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled # before we calculate any access requirements. for door in world.static_logic.objects.doors: if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: continue if door.id in self.item_by_door: continue if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and not self.world.options.shuffle_doors): continue if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and not self.world.options.shuffle_control_center_colors): continue if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings: continue door_item_name = self.world.static_logic.get_door_item_name(door) self.item_by_door[door.id] = (door_item_name, 1) self.real_items.append(door_item_name) # We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle # should be exempt from cyan_door_behavior. if world.options.cyan_door_behavior == CyanDoorBehavior.option_item: for door_group in world.static_logic.objects.door_groups: if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS: continue for door in door_group.doors: if not door in self.item_by_door: self.item_by_door[door] = (door_group.name, 1) self.real_items.append(door_group.name) for door in world.static_logic.objects.doors: if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id, self.get_door_reqs(door.id))) for letter in world.static_logic.objects.letters: self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, AccessRequirements())) behavior = self.get_letter_behavior(letter.key, letter.level2) if behavior == LetterBehavior.VANILLA: letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" event_name = f"{letter_name} (Collected)" self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() if letter.level2: event_name = f"{letter_name} (Double Collected)" self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() elif behavior == LetterBehavior.ITEM: self.real_items.append(letter.key.upper()) if behavior != LetterBehavior.UNLOCKED: self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1 for mastery in world.static_logic.objects.masteries: self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, AccessRequirements())) for ending in world.static_logic.objects.endings: # Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory # condition, since it is necessarily going to be in the postgame. if ending.name == "WHITE": if self.world.options.victory_condition != VictoryCondition.option_white_ending: continue else: self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id, AccessRequirements())) event_name = f"{ending.name.capitalize()} Ending (Achieved)" item_name = event_name if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name: item_name = "Victory" self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name if self.world.options.keyholder_sanity: for keyholder in world.static_logic.objects.keyholders: if keyholder.HasField("key"): reqs = AccessRequirements() if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED: reqs.letters[keyholder.key.upper()] = 1 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id, reqs)) if self.world.options.shuffle_symbols: for symbol_name in SYMBOL_ITEMS.values(): self.real_items.append(symbol_name) def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: if answer is None: if panel_id not in self.panel_reqs: self.panel_reqs[panel_id] = self.calculate_panel_reqs(panel_id, answer) return self.panel_reqs.get(panel_id) else: if panel_id not in self.proxy_reqs or answer not in self.proxy_reqs.get(panel_id): self.proxy_reqs.setdefault(panel_id, {})[answer] = self.calculate_panel_reqs(panel_id, answer) return self.proxy_reqs.get(panel_id).get(answer) def calculate_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: panel = self.world.static_logic.objects.panels[panel_id] reqs = AccessRequirements() reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) if answer is not None: self.add_solution_reqs(reqs, answer) elif len(panel.proxies) > 0: possibilities = [] already_filled = False for proxy in panel.proxies: proxy_reqs = AccessRequirements() self.add_solution_reqs(proxy_reqs, proxy.answer) if not proxy_reqs.is_empty(): possibilities.append(proxy_reqs) else: already_filled = True break if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies): proxy_reqs = AccessRequirements() self.add_solution_reqs(proxy_reqs, panel.answer) if not proxy_reqs.is_empty(): possibilities.append(proxy_reqs) else: already_filled = True if not already_filled: reqs.or_logic.append(possibilities) else: self.add_solution_reqs(reqs, panel.answer) if self.world.options.shuffle_symbols: for symbol in panel.symbols: reqs.items.add(SYMBOL_ITEMS.get(symbol)) if panel.HasField("required_door"): door_reqs = self.get_door_open_reqs(panel.required_door) reqs.merge(door_reqs) if panel.HasField("required_room"): reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.required_room)) return reqs # This gets/calculates the requirements described by the door object. This is most notably used as the requirements # for clearing a location, or opening a door when the door is not shuffled. def get_door_reqs(self, door_id: int) -> AccessRequirements: if door_id not in self.door_reqs: self.door_reqs[door_id] = self.calculate_door_reqs(door_id) return self.door_reqs.get(door_id) def calculate_door_reqs(self, door_id: int) -> AccessRequirements: door = self.world.static_logic.objects.doors[door_id] reqs = AccessRequirements() if not door.HasField("complete_at") or door.complete_at == 0: for proxy in door.panels: panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) reqs.merge(panel_reqs) elif door.complete_at == 1: disjunction = [] for proxy in door.panels: proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) if proxy_reqs.is_empty(): disjunction.clear() break else: disjunction.append(proxy_reqs) if len(disjunction) > 0: reqs.or_logic.append(disjunction) else: reqs.complete_at = door.complete_at for proxy in door.panels: panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) reqs.possibilities.append(panel_reqs) if door.HasField("control_center_color"): # TODO: Logic for ensuring two CC states aren't needed at once. reqs.rooms.add("Control Center - Main Area") self.add_solution_reqs(reqs, door.control_center_color) if door.double_letters: if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2: reqs.rooms.add("The Repetitive - Main Room") elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter: if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked: reqs.cyans = True elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item: # There shouldn't be any locations that are cyan doors. pass for keyholder_uses in door.keyholders: key_name = keyholder_uses.key.upper() if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED and key_name not in reqs.letters): reqs.letters[key_name] = 1 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder] reqs.rooms.add(self.world.static_logic.get_room_region_name(keyholder.room_id)) for room in door.rooms: reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) for ending_id in door.endings: ending = self.world.static_logic.objects.endings[ending_id] if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name: reqs.items.add("Victory") else: reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)") for sub_door_id in door.doors: sub_reqs = self.get_door_open_reqs(sub_door_id) reqs.merge(sub_reqs) reqs.simplify() return reqs # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item # that acts as the door's key. def get_door_open_reqs(self, door_id: int) -> AccessRequirements: if door_id in self.item_by_door: reqs = AccessRequirements() item_name, amount = self.item_by_door.get(door_id) if amount == 1: reqs.items.add(item_name) else: reqs.progressives[item_name] = amount return reqs else: return self.get_door_reqs(door_id) def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior: if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked: return LetterBehavior.UNLOCKED if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]: if level2: if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan: return LetterBehavior.VANILLA else: return LetterBehavior.ITEM else: return LetterBehavior.UNLOCKED if not level2 and letter in ["h", "i", "n", "t"]: return LetterBehavior.UNLOCKED if self.world.options.shuffle_letters == ShuffleLetters.option_progressive: return LetterBehavior.ITEM return LetterBehavior.VANILLA def add_solution_reqs(self, reqs: AccessRequirements, solution: str): histogram = calculate_letter_histogram(solution) for l, a in histogram.items(): needed = min(a, 2) level2 = (needed == 2) if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED: needed = 1 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED: needed = needed - 1 if needed > 0: reqs.letters[l] = max(reqs.letters.get(l, 0), needed)