From ebda0b634c2396338b86b45128bf507c967e88a7 Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Sat, 6 Sep 2025 09:19:22 -0400 Subject: [Apworld] Added letter shuffle --- apworld/__init__.py | 1 + apworld/options.py | 24 +++++++++++ apworld/player_logic.py | 109 ++++++++++++++++++++++++++++++++++++------------ apworld/rules.py | 5 +++ apworld/static_logic.py | 2 +- 5 files changed, 114 insertions(+), 27 deletions(-) diff --git a/apworld/__init__.py b/apworld/__init__.py index 30737f3..8e3066d 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py @@ -69,6 +69,7 @@ class Lingo2World(World): "daedalus_roof_access", "keyholder_sanity", "shuffle_doors", + "shuffle_letters", "victory_condition", ] diff --git a/apworld/options.py b/apworld/options.py index 3216dff..f7dc5bd 100644 --- a/apworld/options.py +++ b/apworld/options.py @@ -8,6 +8,29 @@ class ShuffleDoors(Toggle): display_name = "Shuffle Doors" +class ShuffleLetters(Choice): + """ + Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla + locations in the starting room, even if letters are shuffled remotely. + + - **Vanilla**: All letters will be present at their vanilla locations. + - **Unlocked**: Players will start with their keyboards fully unlocked. + - **Progressive**: Two items will be added to the pool for every letter (one for H, I, N, and T). Receiving the + first item gives you the corresponding level 1 letter, and the second item gives you the corresponding level 2 + letter. + - **Vanilla Cyan**: Players will start with all level 1 (purple) letters unlocked. Level 2 (cyan) letters will be + present at their vanilla locations. + - **Item Cyan**: Players will start with all level 1 (purple) letters unlocked. One item will be added to the pool + for every level 2 (cyan) letter. + """ + display_name = "Shuffle Letters" + option_vanilla = 0 + option_unlocked = 1 + option_progressive = 2 + option_vanilla_cyan = 3 + option_item_cyan = 4 + + class KeyholderSanity(Toggle): """ If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder. @@ -48,6 +71,7 @@ class VictoryCondition(Choice): @dataclass class Lingo2Options(PerGameCommonOptions): shuffle_doors: ShuffleDoors + shuffle_letters: ShuffleLetters keyholder_sanity: KeyholderSanity daedalus_roof_access: DaedalusRoofAccess victory_condition: VictoryCondition diff --git a/apworld/player_logic.py b/apworld/player_logic.py index dc1bdf0..5cb9011 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py @@ -1,7 +1,9 @@ +from enum import IntEnum, auto + from .generated import data_pb2 as data_pb2 from typing import TYPE_CHECKING, NamedTuple -from .options import VictoryCondition +from .options import VictoryCondition, ShuffleLetters if TYPE_CHECKING: from . import Lingo2World @@ -14,10 +16,6 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]: real_l = l.upper() histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2) - for free_letter in "HINT": - if histogram.get(free_letter, 0) == 1: - del histogram[free_letter] - return histogram @@ -27,6 +25,7 @@ class AccessRequirements: rooms: set[str] symbols: set[str] letters: dict[str, int] + cyans: bool # This is an AND of ORs. or_logic: list[list["AccessRequirements"]] @@ -37,14 +36,9 @@ class AccessRequirements: self.rooms = set() self.symbols = set() self.letters = dict() + self.cyans = False self.or_logic = list() - def add_solution(self, solution: str): - histogram = calculate_letter_histogram(solution) - - for l, a in histogram.items(): - self.letters[l] = max(self.letters.get(l, 0), histogram.get(l)) - def merge(self, other: "AccessRequirements"): for item in other.items: self.items.add(item) @@ -61,6 +55,8 @@ class AccessRequirements: 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) @@ -76,6 +72,8 @@ class AccessRequirements: parts.append(f"symbols={self.symbols}") 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}") return f"AccessRequirements({", ".join(parts)})" @@ -86,6 +84,12 @@ class PlayerLocation(NamedTuple): reqs: AccessRequirements +class LetterBehavior(IntEnum): + VANILLA = auto() + ITEM = auto() + UNLOCKED = auto() + + class Lingo2PlayerLogic: world: "Lingo2World" @@ -100,6 +104,8 @@ class Lingo2PlayerLogic: real_items: list[str] + double_letter_amount: dict[str, int] + def __init__(self, world: "Lingo2World"): self.world = world self.locations_by_room = {} @@ -109,6 +115,7 @@ class Lingo2PlayerLogic: 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: @@ -135,14 +142,20 @@ class Lingo2PlayerLogic: 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() - 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 letter.level2: - event_name = f"{letter_name} (Double Collected)" - self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = 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, @@ -170,7 +183,9 @@ class Lingo2PlayerLogic: for keyholder in world.static_logic.objects.keyholders: if keyholder.HasField("key"): reqs = AccessRequirements() - reqs.letters[keyholder.key.upper()] = 1 + + 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)) @@ -194,25 +209,25 @@ class Lingo2PlayerLogic: reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) if answer is not None: - reqs.add_solution(answer) + self.add_solution_reqs(reqs, answer) elif len(panel.proxies) > 0: possibilities = [] for proxy in panel.proxies: proxy_reqs = AccessRequirements() - proxy_reqs.add_solution(proxy.answer) + self.add_solution_reqs(proxy_reqs, proxy.answer) possibilities.append(proxy_reqs) if not any(proxy.answer == panel.answer for proxy in panel.proxies): proxy_reqs = AccessRequirements() - proxy_reqs.add_solution(panel.answer) + self.add_solution_reqs(proxy_reqs, panel.answer) possibilities.append(proxy_reqs) reqs.or_logic.append(possibilities) else: - reqs.add_solution(panel.answer) + self.add_solution_reqs(reqs, panel.answer) for symbol in panel.symbols: reqs.symbols.add(symbol) @@ -254,15 +269,20 @@ class Lingo2PlayerLogic: 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") - reqs.add_solution(door.control_center_color) + self.add_solution_reqs(reqs, door.control_center_color) if door.double_letters: - # TODO: When letter shuffle is on, change this to require any double letter instead. - reqs.rooms.add("The Repetitive - Main Room") + if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla, + ShuffleLetters.option_vanilla_cyan]: + reqs.rooms.add("The Repetitive - Main Room") + elif self.world.options.shuffle_letters in [ShuffleLetters.option_progressive, + ShuffleLetters.option_item_cyan]: + reqs.cyans = True for keyholder_uses in door.keyholders: key_name = keyholder_uses.key.upper() - if key_name not in reqs.letters: + 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] @@ -296,3 +316,40 @@ class Lingo2PlayerLogic: 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) diff --git a/apworld/rules.py b/apworld/rules.py index 5e20de5..56486fa 100644 --- a/apworld/rules.py +++ b/apworld/rules.py @@ -24,6 +24,11 @@ def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirem if not state.has(letter_key, world.player, letter_level): 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()): + 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): diff --git a/apworld/static_logic.py b/apworld/static_logic.py index b33a357..b699d59 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py @@ -31,7 +31,7 @@ class Lingo2StaticLogic: self.location_id_to_name[letter.ap_id] = location_name if not letter.level2: - self.item_id_to_name[letter.ap_id] = letter_name + self.item_id_to_name[letter.ap_id] = letter.key.upper() for mastery in self.objects.masteries: location_name = f"{self.get_room_object_map_name(mastery)} - Mastery" -- cgit 1.4.1