From 8de745f4d3350ac848c9362a33e223c0ff94fdcf Mon Sep 17 00:00:00 2001 From: Star Rauchenberger Date: Tue, 9 Sep 2025 16:44:09 -0400 Subject: Added symbol shuffle Also fixed unlocked letters + any double letter cyan doors, and tweaked some logic related to important panels with symbols on them. --- apworld/__init__.py | 1 + apworld/items.py | 24 ++++++++++++ apworld/options.py | 9 +++++ apworld/player_logic.py | 54 ++++++++++++++++++--------- apworld/rules.py | 2 - apworld/static_logic.py | 4 ++ data/connections.txtpb | 20 ++++++++++ data/ids.yaml | 19 ++++++++++ data/maps/the_entry/rooms/Starting Room.txtpb | 4 +- data/maps/the_great/doors.txtpb | 5 +++ data/maps/the_great/rooms/West Side.txtpb | 1 + data/metadata.txtpb | 22 +++++++++++ proto/human.proto | 4 ++ tools/assign_ids/main.cpp | 18 +++++++++ 14 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 data/metadata.txtpb diff --git a/apworld/__init__.py b/apworld/__init__.py index 6eeee74..fc263c0 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py @@ -82,6 +82,7 @@ class Lingo2World(World): "shuffle_control_center_colors", "shuffle_doors", "shuffle_letters", + "shuffle_symbols", "victory_condition", ] diff --git a/apworld/items.py b/apworld/items.py index 971a709..32568a3 100644 --- a/apworld/items.py +++ b/apworld/items.py @@ -1,5 +1,29 @@ +from .generated import data_pb2 as data_pb2 from BaseClasses import Item class Lingo2Item(Item): game: str = "Lingo 2" + + +SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = { + data_pb2.PuzzleSymbol.SUN: "Sun Symbol", + data_pb2.PuzzleSymbol.SPARKLES: "Sparkles Symbol", + data_pb2.PuzzleSymbol.ZERO: "Zero Symbol", + data_pb2.PuzzleSymbol.EXAMPLE: "Example Symbol", + data_pb2.PuzzleSymbol.BOXES: "Boxes Symbol", + data_pb2.PuzzleSymbol.PLANET: "Planet Symbol", + data_pb2.PuzzleSymbol.PYRAMID: "Pyramid Symbol", + data_pb2.PuzzleSymbol.CROSS: "Cross Symbol", + data_pb2.PuzzleSymbol.SWEET: "Sweet Symbol", + data_pb2.PuzzleSymbol.GENDER: "Gender Symbol", + data_pb2.PuzzleSymbol.AGE: "Age Symbol", + data_pb2.PuzzleSymbol.SOUND: "Sound Symbol", + data_pb2.PuzzleSymbol.ANAGRAM: "Anagram Symbol", + data_pb2.PuzzleSymbol.JOB: "Job Symbol", + data_pb2.PuzzleSymbol.STARS: "Stars Symbol", + data_pb2.PuzzleSymbol.NULL: "Null Symbol", + data_pb2.PuzzleSymbol.EVAL: "Eval Symbol", + data_pb2.PuzzleSymbol.LINGO: "Lingo Symbol", + data_pb2.PuzzleSymbol.QUESTION: "Question Symbol", +} diff --git a/apworld/options.py b/apworld/options.py index 2197b0f..f72e826 100644 --- a/apworld/options.py +++ b/apworld/options.py @@ -39,6 +39,14 @@ class ShuffleLetters(Choice): option_item_cyan = 4 +class ShuffleSymbols(Toggle): + """ + If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel. + Players will be prevented from solving puzzles with symbols on them until all of the required symbols are unlocked. + """ + display_name = "Shuffle Symbols" + + class KeyholderSanity(Toggle): """ If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder. @@ -102,6 +110,7 @@ class Lingo2Options(PerGameCommonOptions): shuffle_doors: ShuffleDoors shuffle_control_center_colors: ShuffleControlCenterColors shuffle_letters: ShuffleLetters + shuffle_symbols: ShuffleSymbols keyholder_sanity: KeyholderSanity cyan_door_behavior: CyanDoorBehavior daedalus_roof_access: DaedalusRoofAccess diff --git a/apworld/player_logic.py b/apworld/player_logic.py index dbd340c..42b36e6 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py @@ -1,6 +1,7 @@ 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 @@ -23,7 +24,6 @@ class AccessRequirements: items: set[str] progressives: dict[str, int] rooms: set[str] - symbols: set[str] letters: dict[str, int] cyans: bool @@ -34,7 +34,6 @@ class AccessRequirements: self.items = set() self.progressives = dict() self.rooms = set() - self.symbols = set() self.letters = dict() self.cyans = False self.or_logic = list() @@ -49,9 +48,6 @@ class AccessRequirements: for room in other.rooms: self.rooms.add(room) - for symbol in other.symbols: - self.symbols.add(symbol) - for letter, level in other.letters.items(): self.letters[letter] = max(self.letters.get(letter, 0), level) @@ -60,6 +56,10 @@ class AccessRequirements: for disjunction in other.or_logic: self.or_logic.append(disjunction) + 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) + def __repr__(self): parts = [] if len(self.items) > 0: @@ -68,8 +68,6 @@ class AccessRequirements: parts.append(f"progressives={self.progressives}") if len(self.rooms) > 0: parts.append(f"rooms={self.rooms}") - if len(self.symbols) > 0: - parts.append(f"symbols={self.symbols}") if len(self.letters) > 0: parts.append(f"letters={self.letters}") if self.cyans: @@ -231,6 +229,10 @@ class Lingo2PlayerLogic: 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: @@ -253,25 +255,35 @@ class Lingo2PlayerLogic: 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) - possibilities.append(proxy_reqs) + if not proxy_reqs.is_empty(): + possibilities.append(proxy_reqs) + else: + already_filled = True + break - if not any(proxy.answer == panel.answer for proxy in panel.proxies): + 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) - possibilities.append(proxy_reqs) + if not proxy_reqs.is_empty(): + possibilities.append(proxy_reqs) + else: + already_filled = True - reqs.or_logic.append(possibilities) + if not already_filled: + reqs.or_logic.append(possibilities) else: self.add_solution_reqs(reqs, panel.answer) - for symbol in panel.symbols: - reqs.symbols.add(symbol) + 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) @@ -300,9 +312,16 @@ class Lingo2PlayerLogic: 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: - reqs.or_logic.append([self.get_panel_reqs(proxy.panel, - proxy.answer if proxy.HasField("answer") else None) - for proxy in door.panels]) + 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: # TODO: Handle complete_at > 1 pass @@ -316,7 +335,8 @@ class Lingo2PlayerLogic: 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: - reqs.cyans = True + 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 diff --git a/apworld/rules.py b/apworld/rules.py index 56486fa..0bff056 100644 --- a/apworld/rules.py +++ b/apworld/rules.py @@ -18,8 +18,6 @@ def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirem if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): return False - # TODO: symbols - for letter_key, letter_level in reqs.letters.items(): if not state.has(letter_key, world.player, letter_level): return False diff --git a/apworld/static_logic.py b/apworld/static_logic.py index 0cc7e55..c112d8e 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py @@ -1,4 +1,5 @@ from .generated import data_pb2 as data_pb2 +from .items import SYMBOL_ITEMS import pkgutil class Lingo2StaticLogic: @@ -64,6 +65,9 @@ class Lingo2StaticLogic: self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done" + for symbol_name in SYMBOL_ITEMS.values(): + self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name + self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} diff --git a/data/connections.txtpb b/data/connections.txtpb index a79778f..d718c96 100644 --- a/data/connections.txtpb +++ b/data/connections.txtpb @@ -841,6 +841,8 @@ connections { } oneway: true } +# Two one-way connections because the CLUE panel only needs to be solved to +# go from The Great to The Partial. connections { from { port { @@ -856,6 +858,24 @@ connections { name: "GREAT" } } + oneway: true +} +connections { + from { + port { + map: "the_partial" + room: "Obverse Side" + name: "GREAT" + } + } + to { + port { + map: "the_great" + room: "West Side" + name: "PARTIAL" + } + } + oneway: true } connections { from { diff --git a/data/ids.yaml b/data/ids.yaml index e2ec985..30a400b 100644 --- a/data/ids.yaml +++ b/data/ids.yaml @@ -3836,6 +3836,25 @@ endings: YELLOW: 1206 special: A Job Well Done: 1160 + Age Symbol: 2791 + Anagram Symbol: 2792 + Boxes Symbol: 2793 + Cross Symbol: 2794 + Eval Symbol: 2795 + Example Symbol: 2796 + Gender Symbol: 2797 + Job Symbol: 2798 + Lingo Symbol: 2799 + Null Symbol: 2800 + Planet Symbol: 2801 + Pyramid Symbol: 2802 + Question Symbol: 2803 + Sound Symbol: 2804 + Sparkles Symbol: 2805 + Stars Symbol: 2806 + Sun Symbol: 2807 + Sweet Symbol: 2808 + Zero Symbol: 2809 progressives: Progressive Gold Ending: 2753 door_groups: diff --git a/data/maps/the_entry/rooms/Starting Room.txtpb b/data/maps/the_entry/rooms/Starting Room.txtpb index bc77e6d..8e8373b 100644 --- a/data/maps/the_entry/rooms/Starting Room.txtpb +++ b/data/maps/the_entry/rooms/Starting Room.txtpb @@ -24,7 +24,9 @@ panels { path: "Panels/Entry/front_1" clue: "eye" answer: "i" - symbols: ZERO + #symbols: ZERO + # This panel blocks getting N1 and T1. We will mod it to be I/I with no symbol + # when symbol shuffle is on. } panels { name: "HINT" diff --git a/data/maps/the_great/doors.txtpb b/data/maps/the_great/doors.txtpb index f0f2fde..5d0e90d 100644 --- a/data/maps/the_great/doors.txtpb +++ b/data/maps/the_great/doors.txtpb @@ -508,3 +508,8 @@ doors { receivers: "Panels/General/entry_7/teleportListener" double_letters: true } +doors { + name: "Partial Entrance" + type: EVENT + panels { room: "West Side" name: "CLUE" } +} diff --git a/data/maps/the_great/rooms/West Side.txtpb b/data/maps/the_great/rooms/West Side.txtpb index daf1718..8279e16 100644 --- a/data/maps/the_great/rooms/West Side.txtpb +++ b/data/maps/the_great/rooms/West Side.txtpb @@ -76,4 +76,5 @@ ports { path: "Meshes/Blocks/Warps/worldport7" orientation: "east" # ER with this is weird; make sure to place on the surface + required_door { name: "Partial Entrance" } } diff --git a/data/metadata.txtpb b/data/metadata.txtpb new file mode 100644 index 0000000..ef66622 --- /dev/null +++ b/data/metadata.txtpb @@ -0,0 +1,22 @@ +# Filler item. +special_names: "A Job Well Done" +# Symbol items. +special_names: "Age Symbol" +special_names: "Anagram Symbol" +special_names: "Boxes Symbol" +special_names: "Cross Symbol" +special_names: "Eval Symbol" +special_names: "Example Symbol" +special_names: "Gender Symbol" +special_names: "Job Symbol" +special_names: "Lingo Symbol" +special_names: "Null Symbol" +special_names: "Planet Symbol" +special_names: "Pyramid Symbol" +special_names: "Question Symbol" +special_names: "Sound Symbol" +special_names: "Sparkles Symbol" +special_names: "Stars Symbol" +special_names: "Sun Symbol" +special_names: "Sweet Symbol" +special_names: "Zero Symbol" diff --git a/proto/human.proto b/proto/human.proto index d48f687..1c5b463 100644 --- a/proto/human.proto +++ b/proto/human.proto @@ -212,6 +212,10 @@ message HumanDoorGroups { repeated HumanDoorGroup door_groups = 1; } +message HumanGlobalMetadata { + repeated string special_names = 1; +} + message IdMappings { message RoomIds { map panels = 1; diff --git a/tools/assign_ids/main.cpp b/tools/assign_ids/main.cpp index ee55338..3e16f78 100644 --- a/tools/assign_ids/main.cpp +++ b/tools/assign_ids/main.cpp @@ -44,6 +44,7 @@ class AssignIds { ProcessSpecialIds(); ProcessProgressivesFile(datadir_path / "progressives.txtpb"); ProcessDoorGroupsFile(datadir_path / "door_groups.txtpb"); + ProcessGlobalMetadataFile(datadir_path / "metadata.txtpb"); WriteIds(ids_path); @@ -288,6 +289,23 @@ class AssignIds { } } + void ProcessGlobalMetadataFile(std::filesystem::path path) { + if (!std::filesystem::exists(path)) { + return; + } + + auto h_metadata = ReadMessageFromFile(path.string()); + auto& specials = *output_.mutable_special(); + + for (const std::string& h_special : h_metadata.special_names()) { + if (!id_mappings_.special().contains(h_special)) { + specials[h_special] = next_id_++; + } else { + specials[h_special] = id_mappings_.special().at(h_special); + } + } + } + private: void UpdateNextId(const google::protobuf::Map& ids) { for (const auto& [_, id] : ids) { -- cgit 1.4.1