diff options
-rw-r--r-- | apworld/__init__.py | 1 | ||||
-rw-r--r-- | apworld/options.py | 24 | ||||
-rw-r--r-- | apworld/player_logic.py | 109 | ||||
-rw-r--r-- | apworld/rules.py | 5 | ||||
-rw-r--r-- | 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): | |||
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 | ||
11 | class 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 | |||
11 | class KeyholderSanity(Toggle): | 34 | class 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 |
49 | class Lingo2Options(PerGameCommonOptions): | 72 | class 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 @@ | |||
1 | from enum import IntEnum, auto | ||
2 | |||
1 | from .generated import data_pb2 as data_pb2 | 3 | from .generated import data_pb2 as data_pb2 |
2 | from typing import TYPE_CHECKING, NamedTuple | 4 | from typing import TYPE_CHECKING, NamedTuple |
3 | 5 | ||
4 | from .options import VictoryCondition | 6 | from .options import VictoryCondition, ShuffleLetters |
5 | 7 | ||
6 | if TYPE_CHECKING: | 8 | if 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 | ||
87 | class LetterBehavior(IntEnum): | ||
88 | VANILLA = auto() | ||
89 | ITEM = auto() | ||
90 | UNLOCKED = auto() | ||
91 | |||
92 | |||
89 | class Lingo2PlayerLogic: | 93 | class 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" |