summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorStar Rauchenberger <fefferburbia@gmail.com>2025-09-06 09:19:22 -0400
committerStar Rauchenberger <fefferburbia@gmail.com>2025-09-06 09:19:22 -0400
commitebda0b634c2396338b86b45128bf507c967e88a7 (patch)
tree80324582c52350c262dd4b5b7a824401ce6ebe4a
parentcc58fb28a7825f562c874b56fa08656096cc6a6c (diff)
downloadlingo2-archipelago-ebda0b634c2396338b86b45128bf507c967e88a7.tar.gz
lingo2-archipelago-ebda0b634c2396338b86b45128bf507c967e88a7.tar.bz2
lingo2-archipelago-ebda0b634c2396338b86b45128bf507c967e88a7.zip
[Apworld] Added letter shuffle
-rw-r--r--apworld/__init__.py1
-rw-r--r--apworld/options.py24
-rw-r--r--apworld/player_logic.py109
-rw-r--r--apworld/rules.py5
-rw-r--r--apworld/static_logic.py2
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):
69 "daedalus_roof_access", 69 "daedalus_roof_access",
70 "keyholder_sanity", 70 "keyholder_sanity",
71 "shuffle_doors", 71 "shuffle_doors",
72 "shuffle_letters",
72 "victory_condition", 73 "victory_condition",
73 ] 74 ]
74 75
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):
8 display_name = "Shuffle Doors" 8 display_name = "Shuffle Doors"
9 9
10 10
11class ShuffleLetters(Choice):
12 """
13 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla
14 locations in the starting room, even if letters are shuffled remotely.
15
16 - **Vanilla**: All letters will be present at their vanilla locations.
17 - **Unlocked**: Players will start with their keyboards fully unlocked.
18 - **Progressive**: Two items will be added to the pool for every letter (one for H, I, N, and T). Receiving the
19 first item gives you the corresponding level 1 letter, and the second item gives you the corresponding level 2
20 letter.
21 - **Vanilla Cyan**: Players will start with all level 1 (purple) letters unlocked. Level 2 (cyan) letters will be
22 present at their vanilla locations.
23 - **Item Cyan**: Players will start with all level 1 (purple) letters unlocked. One item will be added to the pool
24 for every level 2 (cyan) letter.
25 """
26 display_name = "Shuffle Letters"
27 option_vanilla = 0
28 option_unlocked = 1
29 option_progressive = 2
30 option_vanilla_cyan = 3
31 option_item_cyan = 4
32
33
11class KeyholderSanity(Toggle): 34class KeyholderSanity(Toggle):
12 """ 35 """
13 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder. 36 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder.
@@ -48,6 +71,7 @@ class VictoryCondition(Choice):
48@dataclass 71@dataclass
49class Lingo2Options(PerGameCommonOptions): 72class Lingo2Options(PerGameCommonOptions):
50 shuffle_doors: ShuffleDoors 73 shuffle_doors: ShuffleDoors
74 shuffle_letters: ShuffleLetters
51 keyholder_sanity: KeyholderSanity 75 keyholder_sanity: KeyholderSanity
52 daedalus_roof_access: DaedalusRoofAccess 76 daedalus_roof_access: DaedalusRoofAccess
53 victory_condition: VictoryCondition 77 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 @@
1from enum import IntEnum, auto
2
1from .generated import data_pb2 as data_pb2 3from .generated import data_pb2 as data_pb2
2from typing import TYPE_CHECKING, NamedTuple 4from typing import TYPE_CHECKING, NamedTuple
3 5
4from .options import VictoryCondition 6from .options import VictoryCondition, ShuffleLetters
5 7
6if TYPE_CHECKING: 8if TYPE_CHECKING:
7 from . import Lingo2World 9 from . import Lingo2World
@@ -14,10 +16,6 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
14 real_l = l.upper() 16 real_l = l.upper()
15 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2) 17 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
16 18
17 for free_letter in "HINT":
18 if histogram.get(free_letter, 0) == 1:
19 del histogram[free_letter]
20
21 return histogram 19 return histogram
22 20
23 21
@@ -27,6 +25,7 @@ class AccessRequirements:
27 rooms: set[str] 25 rooms: set[str]
28 symbols: set[str] 26 symbols: set[str]
29 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool
30 29
31 # This is an AND of ORs. 30 # This is an AND of ORs.
32 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
@@ -37,14 +36,9 @@ class AccessRequirements:
37 self.rooms = set() 36 self.rooms = set()
38 self.symbols = set() 37 self.symbols = set()
39 self.letters = dict() 38 self.letters = dict()
39 self.cyans = False
40 self.or_logic = list() 40 self.or_logic = list()
41 41
42 def add_solution(self, solution: str):
43 histogram = calculate_letter_histogram(solution)
44
45 for l, a in histogram.items():
46 self.letters[l] = max(self.letters.get(l, 0), histogram.get(l))
47
48 def merge(self, other: "AccessRequirements"): 42 def merge(self, other: "AccessRequirements"):
49 for item in other.items: 43 for item in other.items:
50 self.items.add(item) 44 self.items.add(item)
@@ -61,6 +55,8 @@ class AccessRequirements:
61 for letter, level in other.letters.items(): 55 for letter, level in other.letters.items():
62 self.letters[letter] = max(self.letters.get(letter, 0), level) 56 self.letters[letter] = max(self.letters.get(letter, 0), level)
63 57
58 self.cyans = self.cyans or other.cyans
59
64 for disjunction in other.or_logic: 60 for disjunction in other.or_logic:
65 self.or_logic.append(disjunction) 61 self.or_logic.append(disjunction)
66 62
@@ -76,6 +72,8 @@ class AccessRequirements:
76 parts.append(f"symbols={self.symbols}") 72 parts.append(f"symbols={self.symbols}")
77 if len(self.letters) > 0: 73 if len(self.letters) > 0:
78 parts.append(f"letters={self.letters}") 74 parts.append(f"letters={self.letters}")
75 if self.cyans:
76 parts.append(f"cyans=True")
79 if len(self.or_logic) > 0: 77 if len(self.or_logic) > 0:
80 parts.append(f"or_logic={self.or_logic}") 78 parts.append(f"or_logic={self.or_logic}")
81 return f"AccessRequirements({", ".join(parts)})" 79 return f"AccessRequirements({", ".join(parts)})"
@@ -86,6 +84,12 @@ class PlayerLocation(NamedTuple):
86 reqs: AccessRequirements 84 reqs: AccessRequirements
87 85
88 86
87class LetterBehavior(IntEnum):
88 VANILLA = auto()
89 ITEM = auto()
90 UNLOCKED = auto()
91
92
89class Lingo2PlayerLogic: 93class Lingo2PlayerLogic:
90 world: "Lingo2World" 94 world: "Lingo2World"
91 95
@@ -100,6 +104,8 @@ class Lingo2PlayerLogic:
100 104
101 real_items: list[str] 105 real_items: list[str]
102 106
107 double_letter_amount: dict[str, int]
108
103 def __init__(self, world: "Lingo2World"): 109 def __init__(self, world: "Lingo2World"):
104 self.world = world 110 self.world = world
105 self.locations_by_room = {} 111 self.locations_by_room = {}
@@ -109,6 +115,7 @@ class Lingo2PlayerLogic:
109 self.proxy_reqs = dict() 115 self.proxy_reqs = dict()
110 self.door_reqs = dict() 116 self.door_reqs = dict()
111 self.real_items = list() 117 self.real_items = list()
118 self.double_letter_amount = dict()
112 119
113 if self.world.options.shuffle_doors: 120 if self.world.options.shuffle_doors:
114 for progressive in world.static_logic.objects.progressives: 121 for progressive in world.static_logic.objects.progressives:
@@ -135,14 +142,20 @@ class Lingo2PlayerLogic:
135 for letter in world.static_logic.objects.letters: 142 for letter in world.static_logic.objects.letters:
136 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 143 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
137 AccessRequirements())) 144 AccessRequirements()))
145 behavior = self.get_letter_behavior(letter.key, letter.level2)
146 if behavior == LetterBehavior.VANILLA:
147 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
148 event_name = f"{letter_name} (Collected)"
149 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
138 150
139 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 151 if letter.level2:
140 event_name = f"{letter_name} (Collected)" 152 event_name = f"{letter_name} (Double Collected)"
141 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 153 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
154 elif behavior == LetterBehavior.ITEM:
155 self.real_items.append(letter.key.upper())
142 156
143 if letter.level2: 157 if behavior != LetterBehavior.UNLOCKED:
144 event_name = f"{letter_name} (Double Collected)" 158 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
145 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
146 159
147 for mastery in world.static_logic.objects.masteries: 160 for mastery in world.static_logic.objects.masteries:
148 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 161 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
@@ -170,7 +183,9 @@ class Lingo2PlayerLogic:
170 for keyholder in world.static_logic.objects.keyholders: 183 for keyholder in world.static_logic.objects.keyholders:
171 if keyholder.HasField("key"): 184 if keyholder.HasField("key"):
172 reqs = AccessRequirements() 185 reqs = AccessRequirements()
173 reqs.letters[keyholder.key.upper()] = 1 186
187 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
188 reqs.letters[keyholder.key.upper()] = 1
174 189
175 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id, 190 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
176 reqs)) 191 reqs))
@@ -194,25 +209,25 @@ class Lingo2PlayerLogic:
194 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) 209 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
195 210
196 if answer is not None: 211 if answer is not None:
197 reqs.add_solution(answer) 212 self.add_solution_reqs(reqs, answer)
198 elif len(panel.proxies) > 0: 213 elif len(panel.proxies) > 0:
199 possibilities = [] 214 possibilities = []
200 215
201 for proxy in panel.proxies: 216 for proxy in panel.proxies:
202 proxy_reqs = AccessRequirements() 217 proxy_reqs = AccessRequirements()
203 proxy_reqs.add_solution(proxy.answer) 218 self.add_solution_reqs(proxy_reqs, proxy.answer)
204 219
205 possibilities.append(proxy_reqs) 220 possibilities.append(proxy_reqs)
206 221
207 if not any(proxy.answer == panel.answer for proxy in panel.proxies): 222 if not any(proxy.answer == panel.answer for proxy in panel.proxies):
208 proxy_reqs = AccessRequirements() 223 proxy_reqs = AccessRequirements()
209 proxy_reqs.add_solution(panel.answer) 224 self.add_solution_reqs(proxy_reqs, panel.answer)
210 225
211 possibilities.append(proxy_reqs) 226 possibilities.append(proxy_reqs)
212 227
213 reqs.or_logic.append(possibilities) 228 reqs.or_logic.append(possibilities)
214 else: 229 else:
215 reqs.add_solution(panel.answer) 230 self.add_solution_reqs(reqs, panel.answer)
216 231
217 for symbol in panel.symbols: 232 for symbol in panel.symbols:
218 reqs.symbols.add(symbol) 233 reqs.symbols.add(symbol)
@@ -254,15 +269,20 @@ class Lingo2PlayerLogic:
254 if door.HasField("control_center_color"): 269 if door.HasField("control_center_color"):
255 # TODO: Logic for ensuring two CC states aren't needed at once. 270 # TODO: Logic for ensuring two CC states aren't needed at once.
256 reqs.rooms.add("Control Center - Main Area") 271 reqs.rooms.add("Control Center - Main Area")
257 reqs.add_solution(door.control_center_color) 272 self.add_solution_reqs(reqs, door.control_center_color)
258 273
259 if door.double_letters: 274 if door.double_letters:
260 # TODO: When letter shuffle is on, change this to require any double letter instead. 275 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla,
261 reqs.rooms.add("The Repetitive - Main Room") 276 ShuffleLetters.option_vanilla_cyan]:
277 reqs.rooms.add("The Repetitive - Main Room")
278 elif self.world.options.shuffle_letters in [ShuffleLetters.option_progressive,
279 ShuffleLetters.option_item_cyan]:
280 reqs.cyans = True
262 281
263 for keyholder_uses in door.keyholders: 282 for keyholder_uses in door.keyholders:
264 key_name = keyholder_uses.key.upper() 283 key_name = keyholder_uses.key.upper()
265 if key_name not in reqs.letters: 284 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
285 and key_name not in reqs.letters):
266 reqs.letters[key_name] = 1 286 reqs.letters[key_name] = 1
267 287
268 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder] 288 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
@@ -296,3 +316,40 @@ class Lingo2PlayerLogic:
296 return reqs 316 return reqs
297 else: 317 else:
298 return self.get_door_reqs(door_id) 318 return self.get_door_reqs(door_id)
319
320 def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
321 if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
322 return LetterBehavior.UNLOCKED
323
324 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
325 if level2:
326 if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
327 return LetterBehavior.VANILLA
328 else:
329 return LetterBehavior.ITEM
330 else:
331 return LetterBehavior.UNLOCKED
332
333 if not level2 and letter in ["h", "i", "n", "t"]:
334 return LetterBehavior.UNLOCKED
335
336 if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
337 return LetterBehavior.ITEM
338
339 return LetterBehavior.VANILLA
340
341 def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
342 histogram = calculate_letter_histogram(solution)
343
344 for l, a in histogram.items():
345 needed = min(a, 2)
346 level2 = (needed == 2)
347
348 if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
349 needed = 1
350
351 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
352 needed = needed - 1
353
354 if needed > 0:
355 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
24 if not state.has(letter_key, world.player, letter_level): 24 if not state.has(letter_key, world.player, letter_level):
25 return False 25 return False
26 26
27 if reqs.cyans:
28 if not any(state.has(letter, world.player, amount)
29 for letter, amount in world.player_logic.double_letter_amount.items()):
30 return False
31
27 if len(reqs.or_logic) > 0: 32 if len(reqs.or_logic) > 0:
28 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in subjunction) 33 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in subjunction)
29 for subjunction in reqs.or_logic): 34 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:
31 self.location_id_to_name[letter.ap_id] = location_name 31 self.location_id_to_name[letter.ap_id] = location_name
32 32
33 if not letter.level2: 33 if not letter.level2:
34 self.item_id_to_name[letter.ap_id] = letter_name 34 self.item_id_to_name[letter.ap_id] = letter.key.upper()
35 35
36 for mastery in self.objects.masteries: 36 for mastery in self.objects.masteries:
37 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery" 37 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery"