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" |
