about summary refs log tree commit diff stats
path: root/apworld
diff options
context:
space:
mode:
Diffstat (limited to 'apworld')
-rw-r--r--apworld/README.md48
-rw-r--r--apworld/__init__.py51
-rw-r--r--apworld/docs/en_Lingo_2.md4
-rw-r--r--apworld/items.py24
-rw-r--r--apworld/options.py130
-rw-r--r--apworld/player_logic.py395
-rw-r--r--apworld/regions.py47
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py50
-rw-r--r--apworld/static_logic.py128
-rw-r--r--apworld/version.py1
11 files changed, 816 insertions, 64 deletions
diff --git a/apworld/README.md b/apworld/README.md new file mode 100644 index 0000000..13374b2 --- /dev/null +++ b/apworld/README.md
@@ -0,0 +1,48 @@
1# Lingo 2 Apworld
2
3The Lingo 2 Apworld allows you to generate Archipelago Multiworlds containing
4Lingo 2.
5
6## Installation
7
81. Download the Lingo 2 Apworld from
9 [the releases page](https://code.fourisland.com/lingo2-archipelago/about/apworld/CHANGELOG.md).
102. If you do not already have it, download and install the
11 [Archipelago software](https://github.com/ArchipelagoMW/Archipelago/releases/).
123. Double click on `lingo2.apworld` to install it, or copy it manually to the
13 `custom_worlds` folder of your Archipelago installation.
14
15## Running from source
16
17The apworld is mostly written in Python, which does not need to be compiled.
18However, there are two files that need to be generated before the apworld can be
19used.
20
21The first file is `data.binpb`, the datafile containing the randomizer logic.
22You can read about how to generate it on
23[its own README page](https://code.fourisland.com/lingo2-archipelago/about/data/README.md).
24Once you have it, put it in a subfolder of `apworld` called `generated`.
25
26The second generated file is `data_pb2.py`. This file allows Archipelago to read
27the datafile. We use `protoc`, the Protocol Buffer compiler, to generate it. As
28of 0.6.3, Archipelago has protobuf 3.20.3 packaged with it, which means we need
29to compile our proto file with a similar version.
30
31If you followed the steps to generate `data.binpb` and compiled the `datapacker`
32tool yourself, you will already have protobuf version 3.21.12 installed through
33vcpkg. You can then run a command similar to this in order to generate the
34python file.
35
36```shell
37.\out\build\x64-Debug\vcpkg_installed\x64-windows\tools\protobuf\protoc.exe -Iproto\ ^
38 --python_out=apworld\generated\ .\proto\data.proto
39```
40
41The exact path to `protoc.exe` is going to depend on where vcpkg installed its
42packages. The above location is where Visual Studio will probably put it.
43
44After generating those two files, the apworld should be functional. You can copy
45it into an Archipelago source tree (rename the folder `apworld` to `lingo2` if
46you do so) if you want to edit/debug the code. Otherwise, you can zip up the
47folder and rename it to `lingo2.apworld` in order to package it for
48distribution.
diff --git a/apworld/__init__.py b/apworld/__init__.py index 20c1454..54f870f 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -1,18 +1,27 @@
1""" 1"""
2Archipelago init file for Lingo 2 2Archipelago init file for Lingo 2
3""" 3"""
4from BaseClasses import ItemClassification, Item 4from BaseClasses import ItemClassification, Item, Tutorial
5from worlds.AutoWorld import WebWorld, World 5from worlds.AutoWorld import WebWorld, World
6from .items import Lingo2Item 6from .items import Lingo2Item
7from .options import Lingo2Options 7from .options import Lingo2Options
8from .player_logic import Lingo2PlayerLogic 8from .player_logic import Lingo2PlayerLogic
9from .regions import create_regions 9from .regions import create_regions
10from .static_logic import Lingo2StaticLogic 10from .static_logic import Lingo2StaticLogic
11from .version import APWORLD_VERSION
11 12
12 13
13class Lingo2WebWorld(WebWorld): 14class Lingo2WebWorld(WebWorld):
14 rich_text_options_doc = True 15 rich_text_options_doc = True
15 theme = "grass" 16 theme = "grass"
17 tutorials = [Tutorial(
18 "Multiworld Setup Guide",
19 "A guide to playing Lingo 2 with Archipelago.",
20 "English",
21 "en_Lingo_2.md",
22 "setup/en",
23 ["hatkirby"]
24 )]
16 25
17 26
18class Lingo2World(World): 27class Lingo2World(World):
@@ -24,12 +33,16 @@ class Lingo2World(World):
24 game = "Lingo 2" 33 game = "Lingo 2"
25 web = Lingo2WebWorld() 34 web = Lingo2WebWorld()
26 35
36 topology_present = True
37
27 options_dataclass = Lingo2Options 38 options_dataclass = Lingo2Options
28 options: Lingo2Options 39 options: Lingo2Options
29 40
30 static_logic = Lingo2StaticLogic() 41 static_logic = Lingo2StaticLogic()
31 item_name_to_id = static_logic.item_name_to_id 42 item_name_to_id = static_logic.item_name_to_id
32 location_name_to_id = static_logic.location_name_to_id 43 location_name_to_id = static_logic.location_name_to_id
44 item_name_groups = static_logic.item_name_groups
45 location_name_groups = static_logic.location_name_groups
33 46
34 player_logic: Lingo2PlayerLogic 47 player_logic: Lingo2PlayerLogic
35 48
@@ -46,7 +59,41 @@ class Lingo2World(World):
46 def create_items(self): 59 def create_items(self):
47 pool = [self.create_item(name) for name in self.player_logic.real_items] 60 pool = [self.create_item(name) for name in self.player_logic.real_items]
48 61
62 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values())
63
64 item_difference = total_locations - len(pool)
65 for i in range(0, item_difference):
66 pool.append(self.create_item(self.get_filler_item_name()))
67
49 self.multiworld.itempool += pool 68 self.multiworld.itempool += pool
50 69
51 def create_item(self, name: str) -> Item: 70 def create_item(self, name: str) -> Item:
52 return Lingo2Item(name, ItemClassification.progression, self.item_name_to_id.get(name), self.player) 71 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
72 ItemClassification.progression,
73 self.item_name_to_id.get(name), self.player)
74
75 def set_rules(self):
76 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
77
78 def fill_slot_data(self):
79 slot_options = [
80 "cyan_door_behavior",
81 "daedalus_roof_access",
82 "keyholder_sanity",
83 "shuffle_control_center_colors",
84 "shuffle_doors",
85 "shuffle_gallery_paintings",
86 "shuffle_letters",
87 "shuffle_symbols",
88 "victory_condition",
89 ]
90
91 slot_data = {
92 **self.options.as_dict(*slot_options),
93 "version": [self.static_logic.get_data_version(), APWORLD_VERSION],
94 }
95
96 return slot_data
97
98 def get_filler_item_name(self) -> str:
99 return "A Job Well Done"
diff --git a/apworld/docs/en_Lingo_2.md b/apworld/docs/en_Lingo_2.md new file mode 100644 index 0000000..977795a --- /dev/null +++ b/apworld/docs/en_Lingo_2.md
@@ -0,0 +1,4 @@
1# Lingo 2
2
3See [the project README](https://code.fourisland.com/lingo2-archipelago/about/)
4for installation instructions and frequently asked questions. \ No newline at end of file
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 @@
1from .generated import data_pb2 as data_pb2
1from BaseClasses import Item 2from BaseClasses import Item
2 3
3 4
4class Lingo2Item(Item): 5class Lingo2Item(Item):
5 game: str = "Lingo 2" 6 game: str = "Lingo 2"
7
8
9SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = {
10 data_pb2.PuzzleSymbol.SUN: "Sun Symbol",
11 data_pb2.PuzzleSymbol.SPARKLES: "Sparkles Symbol",
12 data_pb2.PuzzleSymbol.ZERO: "Zero Symbol",
13 data_pb2.PuzzleSymbol.EXAMPLE: "Example Symbol",
14 data_pb2.PuzzleSymbol.BOXES: "Boxes Symbol",
15 data_pb2.PuzzleSymbol.PLANET: "Planet Symbol",
16 data_pb2.PuzzleSymbol.PYRAMID: "Pyramid Symbol",
17 data_pb2.PuzzleSymbol.CROSS: "Cross Symbol",
18 data_pb2.PuzzleSymbol.SWEET: "Sweet Symbol",
19 data_pb2.PuzzleSymbol.GENDER: "Gender Symbol",
20 data_pb2.PuzzleSymbol.AGE: "Age Symbol",
21 data_pb2.PuzzleSymbol.SOUND: "Sound Symbol",
22 data_pb2.PuzzleSymbol.ANAGRAM: "Anagram Symbol",
23 data_pb2.PuzzleSymbol.JOB: "Job Symbol",
24 data_pb2.PuzzleSymbol.STARS: "Stars Symbol",
25 data_pb2.PuzzleSymbol.NULL: "Null Symbol",
26 data_pb2.PuzzleSymbol.EVAL: "Eval Symbol",
27 data_pb2.PuzzleSymbol.LINGO: "Lingo Symbol",
28 data_pb2.PuzzleSymbol.QUESTION: "Question Symbol",
29}
diff --git a/apworld/options.py b/apworld/options.py index 77f0ae3..4f0b32a 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,13 +1,139 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle
4 4
5 5
6class ShuffleDoors(Toggle): 6class ShuffleDoors(DefaultOnToggle):
7 """If enabled, most doors will open from receiving an item rather than fulfilling the in-game requirements.""" 7 """If enabled, most doors will open from receiving an item rather than fulfilling the in-game requirements."""
8 display_name = "Shuffle Doors" 8 display_name = "Shuffle Doors"
9 9
10 10
11class ShuffleControlCenterColors(Toggle):
12 """
13 Some doors open after solving the COLOR panel in the Control Center. If this option is enabled, these doors will
14 instead open upon receiving an item.
15 """
16 display_name = "Shuffle Control Center Colors"
17
18
19class ShuffleGalleryPaintings(Toggle):
20 """If enabled, gallery paintings will appear from receiving an item rather than by triggering them normally."""
21 display_name = "Shuffle Gallery Paintings"
22
23
24class ShuffleLetters(Choice):
25 """
26 Controls how letter unlocks are handled. Note that H1, I1, N1, and T1 will always be present at their vanilla
27 locations in the starting room, even if letters are shuffled remotely.
28
29 - **Vanilla**: All letters will be present at their vanilla locations.
30 - **Unlocked**: Players will start with their keyboards fully unlocked.
31 - **Progressive**: Two items will be added to the pool for every letter (one for H, I, N, and T). Receiving the
32 first item gives you the corresponding level 1 letter, and the second item gives you the corresponding level 2
33 letter.
34 - **Vanilla Cyan**: Players will start with all level 1 (purple) letters unlocked. Level 2 (cyan) letters will be
35 present at their vanilla locations.
36 - **Item Cyan**: Players will start with all level 1 (purple) letters unlocked. One item will be added to the pool
37 for every level 2 (cyan) letter.
38 """
39 display_name = "Shuffle Letters"
40 option_vanilla = 0
41 option_unlocked = 1
42 option_progressive = 2
43 option_vanilla_cyan = 3
44 option_item_cyan = 4
45
46
47class ShuffleSymbols(Toggle):
48 """
49 If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel.
50 Players will be prevented from solving puzzles with symbols on them until all of the required symbols are unlocked.
51 """
52 display_name = "Shuffle Symbols"
53
54
55class KeyholderSanity(Toggle):
56 """
57 If enabled, 26 locations will be created for placing each key into its respective Green Ending keyholder.
58
59 NOTE: This does not apply to the two disappearing keyholders in The Congruent, as they are not part of Green Ending.
60 """
61 display_name = "Keyholder Sanity"
62
63
64class CyanDoorBehavior(Choice):
65 """
66 Cyan-colored doors usually only open upon unlocking double letters. Some panels also only appear upon unlocking
67 double letters. This option determines how these unlocks should behave.
68
69 - **Collect H2**: In the base game, H2 is the first double letter you are intended to collect, so cyan doors only
70 open when you collect the H2 pickup in The Repetitive. Collecting the actual pickup is still required even with
71 remote letter shuffle enabled.
72 - **Any Double Letter**: Cyan doors will open when you have unlocked any cyan letter on your keyboard. In letter
73 shuffle, this means receiving a cyan letter, not picking up a cyan letter collectable.
74 - **Item**: Cyan doors will be grouped together in a single item.
75
76 Note that some cyan doors are impacted by door shuffle (e.g. the entrance to The Tower). When door shuffle is
77 enabled, these doors won't be affected by the value of this option.
78 """
79 display_name = "Cyan Door Behavior"
80 option_collect_h2 = 0
81 option_any_double_letter = 1
82 option_item = 2
83
84
85class DaedalusRoofAccess(Toggle):
86 """
87 If enabled, the player will be logically expected to be able to go from the castle entrance to any part of Daedalus
88 that is open to the air. If disabled, the player will only be expected to be able to enter the castle, the moat,
89 Icarus, and the area at the bottom of the stairs. Invisible walls that become opaque as you approach them are added
90 to the level to prevent the player from accidentally breaking logic.
91 """
92 display_name = "Allow Daedalus Roof Access"
93
94
95class VictoryCondition(Choice):
96 """
97 This option determines what your goal is.
98
99 - **Gray Ending** (The Colorful)
100 - **Purple Ending** (The Sun Temple). This ordinarily requires all level 1 (purple) letters.
101 - **Mint Ending** (typing EXIT into the keyholders in Control Center)
102 - **Black Ending** (The Graveyard)
103 - **Blue Ending** (The Words)
104 - **Cyan Ending** (The Parthenon). This ordinarily requires almost all level 2 (cyan) letters.
105 - **Red Ending** (The Tower)
106 - **Plum Ending** (The Wondrous / The Door)
107 - **Orange Ending** (the castle in Daedalus)
108 - **Gold Ending** (The Gold). This involves going through the color rooms in Daedalus.
109 - **Yellow Ending** (The Gallery). This requires unlocking all gallery paintings.
110 - **Green Ending** (The Ancient). This requires filling all keyholders with specific letters.
111 - **White Ending** (Control Center). This combines every other ending.
112 """
113 display_name = "Victory Condition"
114 option_gray_ending = 0
115 option_purple_ending = 1
116 option_mint_ending = 2
117 option_black_ending = 3
118 option_blue_ending = 4
119 option_cyan_ending = 5
120 option_red_ending = 6
121 option_plum_ending = 7
122 option_orange_ending = 8
123 option_gold_ending = 9
124 option_yellow_ending = 10
125 option_green_ending = 11
126 option_white_ending = 12
127
128
11@dataclass 129@dataclass
12class Lingo2Options(PerGameCommonOptions): 130class Lingo2Options(PerGameCommonOptions):
13 shuffle_doors: ShuffleDoors 131 shuffle_doors: ShuffleDoors
132 shuffle_control_center_colors: ShuffleControlCenterColors
133 shuffle_gallery_paintings: ShuffleGalleryPaintings
134 shuffle_letters: ShuffleLetters
135 shuffle_symbols: ShuffleSymbols
136 keyholder_sanity: KeyholderSanity
137 cyan_door_behavior: CyanDoorBehavior
138 daedalus_roof_access: DaedalusRoofAccess
139 victory_condition: VictoryCondition
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index f67d7f9..8e2a523 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -1,6 +1,11 @@
1from enum import IntEnum, auto
2
1from .generated import data_pb2 as data_pb2 3from .generated import data_pb2 as data_pb2
4from .items import SYMBOL_ITEMS
2from typing import TYPE_CHECKING, NamedTuple 5from typing import TYPE_CHECKING, NamedTuple
3 6
7from .options import VictoryCondition, ShuffleLetters, CyanDoorBehavior
8
4if TYPE_CHECKING: 9if TYPE_CHECKING:
5 from . import Lingo2World 10 from . import Lingo2World
6 11
@@ -10,60 +15,169 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
10 for l in solution: 15 for l in solution:
11 if l.isalpha(): 16 if l.isalpha():
12 real_l = l.upper() 17 real_l = l.upper()
13 histogram[real_l] = min(histogram.get(l, 0) + 1, 2) 18 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
14 19
15 return histogram 20 return histogram
16 21
17 22
18class AccessRequirements: 23class AccessRequirements:
19 items: set[str] 24 items: set[str]
25 progressives: dict[str, int]
20 rooms: set[str] 26 rooms: set[str]
21 symbols: set[str]
22 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool
23 29
24 # This is an AND of ORs. 30 # This is an AND of ORs.
25 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
26 32
33 # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
34 # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
35 complete_at: int | None
36 possibilities: list["AccessRequirements"]
37
27 def __init__(self): 38 def __init__(self):
28 self.items = set() 39 self.items = set()
40 self.progressives = dict()
29 self.rooms = set() 41 self.rooms = set()
30 self.symbols = set()
31 self.letters = dict() 42 self.letters = dict()
43 self.cyans = False
32 self.or_logic = list() 44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
33 47
34 def add_solution(self, solution: str): 48 def copy(self) -> "AccessRequirements":
35 histogram = calculate_letter_histogram(solution) 49 reqs = AccessRequirements()
36 50 reqs.items = self.items.copy()
37 for l, a in histogram.items(): 51 reqs.progressives = self.progressives.copy()
38 self.letters[l] = max(self.letters.get(l, 0), histogram.get(l)) 52 reqs.rooms = self.rooms.copy()
53 reqs.letters = self.letters.copy()
54 reqs.cyans = self.cyans
55 reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
56 reqs.complete_at = self.complete_at
57 reqs.possibilities = self.possibilities.copy()
58 return reqs
39 59
40 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
41 for item in other.items: 61 for item in other.items:
42 self.items.add(item) 62 self.items.add(item)
43 63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
44 for room in other.rooms: 67 for room in other.rooms:
45 self.rooms.add(room) 68 self.rooms.add(room)
46 69
47 for symbol in other.symbols:
48 self.symbols.add(symbol)
49
50 for letter, level in other.letters.items(): 70 for letter, level in other.letters.items():
51 self.letters[letter] = max(self.letters.get(letter, 0), level) 71 self.letters[letter] = max(self.letters.get(letter, 0), level)
52 72
73 self.cyans = self.cyans or other.cyans
74
53 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
54 self.or_logic.append(disjunction) 76 self.or_logic.append(disjunction)
55 77
78 if other.complete_at is not None:
79 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
80 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
81 # conjunctions of requirements.
82 if self.complete_at is not None:
83 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
84
85 left_req = AccessRequirements()
86 left_req.complete_at = self.complete_at
87 left_req.possibilities = self.possibilities
88 self.or_logic.append([left_req])
89
90 self.complete_at = None
91 self.possibilities = list()
92
93 right_req = AccessRequirements()
94 right_req.complete_at = other.complete_at
95 right_req.possibilities = other.possibilities
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = other.possibilities
100
101 def is_empty(self) -> bool:
102 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
103 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
104
105 def __eq__(self, other: "AccessRequirements"):
106 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
107 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
108 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
109
110 def simplify(self):
111 resimplify = False
112
113 if len(self.or_logic) > 0:
114 old_or_logic = self.or_logic
115
116 def remove_redundant(sub_reqs: "AccessRequirements"):
117 sub_reqs.letters = {l: v for l, v in sub_reqs.letters.items() if self.letters.get(l, 0) < v}
118
119 self.or_logic = []
120 for disjunction in old_or_logic:
121 new_disjunction = []
122 for ssr in disjunction:
123 remove_redundant(ssr)
124 if not ssr.is_empty():
125 new_disjunction.append(ssr)
126 else:
127 new_disjunction.clear()
128 break
129 if len(new_disjunction) == 1:
130 self.merge(new_disjunction[0])
131 resimplify = True
132 elif len(new_disjunction) > 1:
133 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
134 self.merge(new_disjunction[0])
135 resimplify = True
136 else:
137 self.or_logic.append(new_disjunction)
138
139 if resimplify:
140 self.simplify()
141
142 def __repr__(self):
143 parts = []
144 if len(self.items) > 0:
145 parts.append(f"items={self.items}")
146 if len(self.progressives) > 0:
147 parts.append(f"progressives={self.progressives}")
148 if len(self.rooms) > 0:
149 parts.append(f"rooms={self.rooms}")
150 if len(self.letters) > 0:
151 parts.append(f"letters={self.letters}")
152 if self.cyans:
153 parts.append(f"cyans=True")
154 if len(self.or_logic) > 0:
155 parts.append(f"or_logic={self.or_logic}")
156 if self.complete_at is not None:
157 parts.append(f"complete_at={self.complete_at}")
158 if len(self.possibilities) > 0:
159 parts.append(f"possibilities={self.possibilities}")
160 return f"AccessRequirements({", ".join(parts)})"
161
56 162
57class PlayerLocation(NamedTuple): 163class PlayerLocation(NamedTuple):
58 code: int | None 164 code: int | None
59 reqs: AccessRequirements 165 reqs: AccessRequirements
60 166
61 167
168class LetterBehavior(IntEnum):
169 VANILLA = auto()
170 ITEM = auto()
171 UNLOCKED = auto()
172
173
62class Lingo2PlayerLogic: 174class Lingo2PlayerLogic:
63 world: "Lingo2World" 175 world: "Lingo2World"
64 176
65 locations_by_room: dict[int, list[PlayerLocation]] 177 locations_by_room: dict[int, list[PlayerLocation]]
66 item_by_door: dict[int, str] 178 event_loc_item_by_room: dict[int, dict[str, str]]
179
180 item_by_door: dict[int, tuple[str, int]]
67 181
68 panel_reqs: dict[int, AccessRequirements] 182 panel_reqs: dict[int, AccessRequirements]
69 proxy_reqs: dict[int, dict[str, AccessRequirements]] 183 proxy_reqs: dict[int, dict[str, AccessRequirements]]
@@ -71,22 +185,79 @@ class Lingo2PlayerLogic:
71 185
72 real_items: list[str] 186 real_items: list[str]
73 187
188 double_letter_amount: dict[str, int]
189
74 def __init__(self, world: "Lingo2World"): 190 def __init__(self, world: "Lingo2World"):
75 self.world = world 191 self.world = world
76 self.locations_by_room = {} 192 self.locations_by_room = {}
193 self.event_loc_item_by_room = {}
77 self.item_by_door = {} 194 self.item_by_door = {}
78 self.panel_reqs = dict() 195 self.panel_reqs = dict()
79 self.proxy_reqs = dict() 196 self.proxy_reqs = dict()
80 self.door_reqs = dict() 197 self.door_reqs = dict()
81 self.real_items = list() 198 self.real_items = list()
199 self.double_letter_amount = dict()
200
201 if self.world.options.shuffle_doors:
202 for progressive in world.static_logic.objects.progressives:
203 for i in range(0, len(progressive.doors)):
204 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
205 self.real_items.append(progressive.name)
206
207 for door_group in world.static_logic.objects.door_groups:
208 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
209 if not self.world.options.shuffle_doors:
210 continue
211 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
212 if not self.world.options.shuffle_control_center_colors:
213 continue
214 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
215 if not self.world.options.shuffle_doors:
216 continue
217 else:
218 continue
219
220 for door in door_group.doors:
221 self.item_by_door[door] = (door_group.name, 1)
222
223 self.real_items.append(door_group.name)
82 224
83 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 225 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
84 # before we calculate any access requirements. 226 # before we calculate any access requirements.
85 for door in world.static_logic.objects.doors: 227 for door in world.static_logic.objects.doors:
86 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: 228 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
87 door_item_name = self.world.static_logic.get_door_item_name(door.id) 229 continue
88 self.item_by_door[door.id] = door_item_name 230
89 self.real_items.append(door_item_name) 231 if door.id in self.item_by_door:
232 continue
233
234 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
235 not self.world.options.shuffle_doors):
236 continue
237
238 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
239 not self.world.options.shuffle_control_center_colors):
240 continue
241
242 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
243 continue
244
245 door_item_name = self.world.static_logic.get_door_item_name(door)
246 self.item_by_door[door.id] = (door_item_name, 1)
247 self.real_items.append(door_item_name)
248
249 # We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle
250 # should be exempt from cyan_door_behavior.
251 if world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
252 for door_group in world.static_logic.objects.door_groups:
253 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
254 continue
255
256 for door in door_group.doors:
257 if not door in self.item_by_door:
258 self.item_by_door[door] = (door_group.name, 1)
259
260 self.real_items.append(door_group.name)
90 261
91 for door in world.static_logic.objects.doors: 262 for door in world.static_logic.objects.doors:
92 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 263 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
@@ -96,11 +267,58 @@ class Lingo2PlayerLogic:
96 for letter in world.static_logic.objects.letters: 267 for letter in world.static_logic.objects.letters:
97 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 268 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
98 AccessRequirements())) 269 AccessRequirements()))
270 behavior = self.get_letter_behavior(letter.key, letter.level2)
271 if behavior == LetterBehavior.VANILLA:
272 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
273 event_name = f"{letter_name} (Collected)"
274 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
275
276 if letter.level2:
277 event_name = f"{letter_name} (Double Collected)"
278 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
279 elif behavior == LetterBehavior.ITEM:
280 self.real_items.append(letter.key.upper())
281
282 if behavior != LetterBehavior.UNLOCKED:
283 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
99 284
100 for mastery in world.static_logic.objects.masteries: 285 for mastery in world.static_logic.objects.masteries:
101 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 286 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
102 AccessRequirements())) 287 AccessRequirements()))
103 288
289 for ending in world.static_logic.objects.endings:
290 # Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory
291 # condition, since it is necessarily going to be in the postgame.
292 if ending.name == "WHITE":
293 if self.world.options.victory_condition != VictoryCondition.option_white_ending:
294 continue
295 else:
296 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
297 AccessRequirements()))
298
299 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
300 item_name = event_name
301
302 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
303 item_name = "Victory"
304
305 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
306
307 if self.world.options.keyholder_sanity:
308 for keyholder in world.static_logic.objects.keyholders:
309 if keyholder.HasField("key"):
310 reqs = AccessRequirements()
311
312 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
313 reqs.letters[keyholder.key.upper()] = 1
314
315 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
316 reqs))
317
318 if self.world.options.shuffle_symbols:
319 for symbol_name in SYMBOL_ITEMS.values():
320 self.real_items.append(symbol_name)
321
104 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 322 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
105 if answer is None: 323 if answer is None:
106 if panel_id not in self.panel_reqs: 324 if panel_id not in self.panel_reqs:
@@ -120,18 +338,38 @@ class Lingo2PlayerLogic:
120 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) 338 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
121 339
122 if answer is not None: 340 if answer is not None:
123 reqs.add_solution(answer) 341 self.add_solution_reqs(reqs, answer)
124 elif len(panel.proxies) > 0: 342 elif len(panel.proxies) > 0:
343 possibilities = []
344 already_filled = False
345
125 for proxy in panel.proxies: 346 for proxy in panel.proxies:
126 proxy_reqs = AccessRequirements() 347 proxy_reqs = AccessRequirements()
127 proxy_reqs.add_solution(proxy.answer) 348 self.add_solution_reqs(proxy_reqs, proxy.answer)
349
350 if not proxy_reqs.is_empty():
351 possibilities.append(proxy_reqs)
352 else:
353 already_filled = True
354 break
355
356 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
357 proxy_reqs = AccessRequirements()
358 self.add_solution_reqs(proxy_reqs, panel.answer)
359
360 if not proxy_reqs.is_empty():
361 possibilities.append(proxy_reqs)
362 else:
363 already_filled = True
128 364
129 reqs.or_logic.append([proxy_reqs]) 365 if not already_filled:
366 reqs.or_logic.append(possibilities)
130 else: 367 else:
131 reqs.add_solution(panel.answer) 368 self.add_solution_reqs(reqs, panel.answer)
132 369
133 for symbol in panel.symbols: 370 if self.world.options.shuffle_symbols:
134 reqs.symbols.add(symbol) 371 for symbol in panel.symbols:
372 reqs.items.add(SYMBOL_ITEMS.get(symbol))
135 373
136 if panel.HasField("required_door"): 374 if panel.HasField("required_door"):
137 door_reqs = self.get_door_open_reqs(panel.required_door) 375 door_reqs = self.get_door_open_reqs(panel.required_door)
@@ -154,24 +392,67 @@ class Lingo2PlayerLogic:
154 door = self.world.static_logic.objects.doors[door_id] 392 door = self.world.static_logic.objects.doors[door_id]
155 reqs = AccessRequirements() 393 reqs = AccessRequirements()
156 394
157 use_item = False 395 if not door.HasField("complete_at") or door.complete_at == 0:
158 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors:
159 use_item = True
160
161 if use_item:
162 reqs.items.add(self.world.static_logic.get_door_item_name(door.id))
163 else:
164 # TODO: complete_at, control_center_color, switches, keyholders
165 for proxy in door.panels: 396 for proxy in door.panels:
166 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 397 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
167 reqs.merge(panel_reqs) 398 reqs.merge(panel_reqs)
399 elif door.complete_at == 1:
400 disjunction = []
401 for proxy in door.panels:
402 proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
403 if proxy_reqs.is_empty():
404 disjunction.clear()
405 break
406 else:
407 disjunction.append(proxy_reqs)
408 if len(disjunction) > 0:
409 reqs.or_logic.append(disjunction)
410 else:
411 reqs.complete_at = door.complete_at
412 for proxy in door.panels:
413 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
414 reqs.possibilities.append(panel_reqs)
415
416 if door.HasField("control_center_color"):
417 # TODO: Logic for ensuring two CC states aren't needed at once.
418 reqs.rooms.add("Control Center - Main Area")
419 self.add_solution_reqs(reqs, door.control_center_color)
420
421 if door.double_letters:
422 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
423 reqs.rooms.add("The Repetitive - Main Room")
424 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
425 if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
426 reqs.cyans = True
427 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
428 # There shouldn't be any locations that are cyan doors.
429 pass
430
431 for keyholder_uses in door.keyholders:
432 key_name = keyholder_uses.key.upper()
433 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
434 and key_name not in reqs.letters):
435 reqs.letters[key_name] = 1
436
437 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
438 reqs.rooms.add(self.world.static_logic.get_room_region_name(keyholder.room_id))
168 439
169 for room in door.rooms: 440 for room in door.rooms:
170 reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) 441 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
171 442
172 for sub_door_id in door.doors: 443 for ending_id in door.endings:
173 sub_reqs = self.get_door_open_reqs(sub_door_id) 444 ending = self.world.static_logic.objects.endings[ending_id]
174 reqs.merge(sub_reqs) 445
446 if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
447 reqs.items.add("Victory")
448 else:
449 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
450
451 for sub_door_id in door.doors:
452 sub_reqs = self.get_door_open_reqs(sub_door_id)
453 reqs.merge(sub_reqs)
454
455 reqs.simplify()
175 456
176 return reqs 457 return reqs
177 458
@@ -180,8 +461,50 @@ class Lingo2PlayerLogic:
180 def get_door_open_reqs(self, door_id: int) -> AccessRequirements: 461 def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
181 if door_id in self.item_by_door: 462 if door_id in self.item_by_door:
182 reqs = AccessRequirements() 463 reqs = AccessRequirements()
183 reqs.items.add(self.item_by_door.get(door_id)) 464
465 item_name, amount = self.item_by_door.get(door_id)
466 if amount == 1:
467 reqs.items.add(item_name)
468 else:
469 reqs.progressives[item_name] = amount
184 470
185 return reqs 471 return reqs
186 else: 472 else:
187 return self.get_door_reqs(door_id) 473 return self.get_door_reqs(door_id)
474
475 def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
476 if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
477 return LetterBehavior.UNLOCKED
478
479 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
480 if level2:
481 if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
482 return LetterBehavior.VANILLA
483 else:
484 return LetterBehavior.ITEM
485 else:
486 return LetterBehavior.UNLOCKED
487
488 if not level2 and letter in ["h", "i", "n", "t"]:
489 return LetterBehavior.UNLOCKED
490
491 if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
492 return LetterBehavior.ITEM
493
494 return LetterBehavior.VANILLA
495
496 def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
497 histogram = calculate_letter_histogram(solution)
498
499 for l, a in histogram.items():
500 needed = min(a, 2)
501 level2 = (needed == 2)
502
503 if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
504 needed = 1
505
506 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
507 needed = needed - 1
508
509 if needed > 0:
510 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
diff --git a/apworld/regions.py b/apworld/regions.py index 14fcaac..4f1dd55 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -1,6 +1,7 @@
1from typing import TYPE_CHECKING 1from typing import TYPE_CHECKING
2 2
3from BaseClasses import Region 3from BaseClasses import Region, ItemClassification, Entrance
4from .items import Lingo2Item
4from .locations import Lingo2Location 5from .locations import Lingo2Location
5from .player_logic import AccessRequirements 6from .player_logic import AccessRequirements
6from .rules import make_location_lambda 7from .rules import make_location_lambda
@@ -10,30 +11,51 @@ if TYPE_CHECKING:
10 11
11 12
12def create_region(room, world: "Lingo2World") -> Region: 13def create_region(room, world: "Lingo2World") -> Region:
13 new_region = Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld) 14 return Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld)
14 15
16
17def create_locations(room, new_region: Region, world: "Lingo2World", regions: dict[str, Region]):
15 for location in world.player_logic.locations_by_room.get(room.id, {}): 18 for location in world.player_logic.locations_by_room.get(room.id, {}):
19 reqs = location.reqs.copy()
20 if new_region.name in reqs.rooms:
21 reqs.rooms.remove(new_region.name)
22
16 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 23 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code],
17 location.code, new_region) 24 location.code, new_region)
18 new_location.access_rule = make_location_lambda(location.reqs, world) 25 new_location.access_rule = make_location_lambda(reqs, world, regions)
19 new_region.locations.append(new_location) 26 new_region.locations.append(new_location)
20 27
21 return new_region 28 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
22 29 new_location = Lingo2Location(world.player, event_name, None, new_region)
30 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player)
31 new_location.place_locked_item(event_item)
32 new_region.locations.append(new_location)
23 33
24def create_regions(world: "Lingo2World"): 34def create_regions(world: "Lingo2World"):
25 regions = { 35 regions = {
26 "Menu": Region("Menu", world.player, world.multiworld) 36 "Menu": Region("Menu", world.player, world.multiworld)
27 } 37 }
28 38
39 region_and_room = []
40
41 # Create the regions in two stages. First, make the actual region objects and memoize them. Then, add all of the
42 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is
43 # faster than having to look them up during access checking.
29 for room in world.static_logic.objects.rooms: 44 for room in world.static_logic.objects.rooms:
30 region = create_region(room, world) 45 region = create_region(room, world)
31 regions[region.name] = region 46 regions[region.name] = region
47 region_and_room.append((region, room))
32 48
33 regions["Menu"].connect(regions["the_entry - Starting Room"], "Start Game") 49 for (region, room) in region_and_room:
50 create_locations(room, region, world, regions)
51
52 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
34 53
35 # TODO: The requirements of the opposite trigger also matter. 54 # TODO: The requirements of the opposite trigger also matter.
36 for connection in world.static_logic.objects.connections: 55 for connection in world.static_logic.objects.connections:
56 if connection.roof_access and not world.options.daedalus_roof_access:
57 continue
58
37 from_region = world.static_logic.get_room_region_name(connection.from_room) 59 from_region = world.static_logic.get_room_region_name(connection.from_room)
38 to_region = world.static_logic.get_room_region_name(connection.to_room) 60 to_region = world.static_logic.get_room_region_name(connection.to_room)
39 connection_name = f"{from_region} -> {to_region}" 61 connection_name = f"{from_region} -> {to_region}"
@@ -72,7 +94,18 @@ def create_regions(world: "Lingo2World"):
72 else: 94 else:
73 connection_name = f"{connection_name} (via panel {panel.name})" 95 connection_name = f"{connection_name} (via panel {panel.name})"
74 96
97 reqs.simplify()
98
75 if from_region in regions and to_region in regions: 99 if from_region in regions and to_region in regions:
76 regions[from_region].connect(regions[to_region], connection_name, make_location_lambda(reqs, world)) 100 connection = Entrance(world.player, connection_name, regions[from_region])
101 connection.access_rule = make_location_lambda(reqs, world, regions)
102
103 regions[from_region].exits.append(connection)
104 connection.connect(regions[to_region])
105
106 for region in reqs.rooms:
107 if region == from_region:
108 continue
109 world.multiworld.register_indirect_condition(regions[region], connection)
77 110
78 world.multiworld.regions += regions.values() 111 world.multiworld.regions += regions.values()
diff --git a/apworld/requirements.txt b/apworld/requirements.txt index b701d11..dbc395b 100644 --- a/apworld/requirements.txt +++ b/apworld/requirements.txt
@@ -1 +1 @@
protobuf>=5.29.3 \ No newline at end of file protobuf==3.20.3
diff --git a/apworld/rules.py b/apworld/rules.py index 05689e9..c077858 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,27 +1,63 @@
1from collections.abc import Callable 1from collections.abc import Callable
2from typing import TYPE_CHECKING 2from typing import TYPE_CHECKING
3 3
4from BaseClasses import CollectionState 4from BaseClasses import CollectionState, Region
5from .player_logic import AccessRequirements 5from .player_logic import AccessRequirements
6 6
7if TYPE_CHECKING: 7if TYPE_CHECKING:
8 from . import Lingo2World 8 from . import Lingo2World
9 9
10 10
11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, world: "Lingo2World") -> bool: 11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region],
12 world: "Lingo2World") -> bool:
12 if not all(state.has(item, world.player) for item in reqs.items): 13 if not all(state.has(item, world.player) for item in reqs.items):
13 return False 14 return False
14 15
16 if not all(state.has(item, world.player, amount) for item, amount in reqs.progressives.items()):
17 return False
18
15 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): 19 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms):
16 return False 20 return False
17 21
18 # TODO: symbols, letters 22 if not all(state.can_reach(region) for region in regions):
23 return False
24
25 for letter_key, letter_level in reqs.letters.items():
26 if not state.has(letter_key, world.player, letter_level):
27 return False
28
29 if reqs.cyans:
30 if not any(state.has(letter, world.player, amount)
31 for letter, amount in world.player_logic.double_letter_amount.items()):
32 return False
33
34 if len(reqs.or_logic) > 0:
35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
36 for subjunction in reqs.or_logic):
37 return False
19 38
20 for disjunction in reqs.or_logic: 39 if reqs.complete_at is not None:
21 if not any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in disjunction): 40 completed = 0
41 checked = 0
42 for possibility in reqs.possibilities:
43 checked += 1
44 if lingo2_can_satisfy_requirements(state, possibility, [], world):
45 completed += 1
46 if completed >= reqs.complete_at:
47 break
48 elif len(reqs.possibilities) - checked + completed < reqs.complete_at:
49 # There aren't enough remaining possibilities for the check to pass.
50 return False
51 if completed < reqs.complete_at:
22 return False 52 return False
23 53
24 return True 54 return True
25 55
26def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: 56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
27 return lambda state: lingo2_can_satisfy_requirements(state, reqs, world) 57 regions: dict[str, Region]) -> Callable[[CollectionState], bool]:
58 # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule
59 # checking.
60 required_regions = [regions[room_name] for room_name in reqs.rooms]
61 new_reqs = reqs.copy()
62 new_reqs.rooms.clear()
63 return lambda state: lingo2_can_satisfy_requirements(state, new_reqs, required_regions, world)
diff --git a/apworld/static_logic.py b/apworld/static_logic.py index af1e985..1ace1e7 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py
@@ -1,4 +1,5 @@
1from .generated import data_pb2 as data_pb2 1from .generated import data_pb2 as data_pb2
2from .items import SYMBOL_ITEMS
2import pkgutil 3import pkgutil
3 4
4class Lingo2StaticLogic: 5class Lingo2StaticLogic:
@@ -8,9 +9,14 @@ class Lingo2StaticLogic:
8 item_name_to_id: dict[str, int] 9 item_name_to_id: dict[str, int]
9 location_name_to_id: dict[str, int] 10 location_name_to_id: dict[str, int]
10 11
12 item_name_groups: dict[str, list[str]]
13 location_name_groups: dict[str, list[str]]
14
11 def __init__(self): 15 def __init__(self):
12 self.item_id_to_name = {} 16 self.item_id_to_name = {}
13 self.location_id_to_name = {} 17 self.location_id_to_name = {}
18 self.item_name_groups = {}
19 self.location_name_groups = {}
14 20
15 file = pkgutil.get_data(__name__, "generated/data.binpb") 21 file = pkgutil.get_data(__name__, "generated/data.binpb")
16 self.objects = data_pb2.AllObjects() 22 self.objects = data_pb2.AllObjects()
@@ -18,32 +24,136 @@ class Lingo2StaticLogic:
18 24
19 for door in self.objects.doors: 25 for door in self.objects.doors:
20 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 26 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
21 location_name = f"{self.objects.maps[door.map_id].name} - {door.name}" 27 location_name = self.get_door_location_name(door)
22 self.location_id_to_name[door.ap_id] = location_name 28 self.location_id_to_name[door.ap_id] = location_name
23 29
24 if door.type not in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 30 if door.type not in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
25 item_name = self.get_door_item_name(door.id) 31 item_name = self.get_door_item_name(door)
26 self.item_id_to_name[door.ap_id] = item_name 32 self.item_id_to_name[door.ap_id] = item_name
27 33
28 for letter in self.objects.letters: 34 for letter in self.objects.letters:
29 letter_name = f"{letter.key.upper()}{'' if letter.level2 else '2'}" 35 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
30 location_name = f"{self.objects.maps[self.objects.rooms[letter.room_id].map_id].name} - {letter_name}" 36 location_name = f"{self.get_room_object_map_name(letter)} - {letter_name}"
31 self.location_id_to_name[letter.ap_id] = location_name 37 self.location_id_to_name[letter.ap_id] = location_name
38 self.location_name_groups.setdefault("Letters", []).append(location_name)
32 39
33 if not letter.level2: 40 if not letter.level2:
34 self.item_id_to_name[letter.ap_id] = letter_name 41 self.item_id_to_name[letter.ap_id] = letter.key.upper()
42 self.item_name_groups.setdefault("Letters", []).append(letter.key.upper())
35 43
36 for mastery in self.objects.masteries: 44 for mastery in self.objects.masteries:
37 location_name = f"{self.objects.maps[self.objects.rooms[mastery.room_id].map_id].name} - Mastery" 45 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery"
38 self.location_id_to_name[mastery.ap_id] = location_name 46 self.location_id_to_name[mastery.ap_id] = location_name
47 self.location_name_groups.setdefault("Masteries", []).append(location_name)
48
49 for ending in self.objects.endings:
50 location_name = f"{self.get_room_object_map_name(ending)} - {ending.name.title()} Ending"
51 self.location_id_to_name[ending.ap_id] = location_name
52 self.location_name_groups.setdefault("Endings", []).append(location_name)
53
54 for progressive in self.objects.progressives:
55 self.item_id_to_name[progressive.ap_id] = progressive.name
56
57 for door_group in self.objects.door_groups:
58 self.item_id_to_name[door_group.ap_id] = door_group.name
59
60 for keyholder in self.objects.keyholders:
61 if keyholder.HasField("key"):
62 location_name = f"{self.get_room_object_location_prefix(keyholder)} - {keyholder.key.upper()} Keyholder"
63 self.location_id_to_name[keyholder.ap_id] = location_name
64 self.location_name_groups.setdefault("Keyholders", []).append(location_name)
65
66 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done"
67
68 for symbol_name in SYMBOL_ITEMS.values():
69 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name
39 70
40 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} 71 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()}
41 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} 72 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()}
42 73
43 def get_door_item_name(self, door_id: int) -> str: 74 def get_door_item_name(self, door: data_pb2.Door) -> str:
75 return f"{self.get_map_object_map_name(door)} - {door.name}"
76
77 def get_door_item_name_by_id(self, door_id: int) -> str:
44 door = self.objects.doors[door_id] 78 door = self.objects.doors[door_id]
45 return f"{self.objects.maps[door.map_id].name} - {door.name}" 79 return self.get_door_item_name(door_id)
80
81 def get_door_location_name(self, door: data_pb2.Door) -> str:
82 map_part = self.get_room_object_location_prefix(door)
83
84 if door.HasField("location_name"):
85 return f"{map_part} - {door.location_name}"
86
87 generated_location_name = self.get_generated_door_location_name(door)
88 if generated_location_name is not None:
89 return generated_location_name
90
91 return f"{map_part} - {door.name}"
92
93 def get_generated_door_location_name(self, door: data_pb2.Door) -> str | None:
94 if door.type != data_pb2.DoorType.STANDARD:
95 return None
96
97 if len(door.keyholders) > 0 or len(door.endings) > 0 or not door.HasField("complete_at"):
98 return None
99
100 if len(door.panels) > 4:
101 return None
102
103 map_areas = set()
104 for panel_id in door.panels:
105 panel = self.objects.panels[panel_id.panel]
106 panel_room = self.objects.rooms[panel.room_id]
107 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
108 map_areas.add(panel_room.panel_display_name)
109
110 if len(map_areas) > 1:
111 return None
112
113 game_map = self.objects.maps[door.map_id]
114 map_area = map_areas.pop()
115 if map_area == "":
116 map_part = game_map.display_name
117 else:
118 map_part = f"{game_map.display_name} ({map_area})"
119
120 def get_panel_display_name(panel: data_pb2.ProxyIdentifier) -> str:
121 panel_data = self.objects.panels[panel.panel]
122 panel_name = panel_data.display_name if panel_data.HasField("display_name") else panel_data.name
123
124 if panel.HasField("answer"):
125 return f"{panel_name}/{panel.answer.upper()}"
126 else:
127 return panel_name
128
129 panel_names = [get_panel_display_name(panel_id)
130 for panel_id in door.panels]
131 panel_names.sort()
132
133 return f"{map_part} - {", ".join(panel_names)}"
134
135 def get_door_location_name_by_id(self, door_id: int) -> str:
136 door = self.objects.doors[door_id]
137 return self.get_door_location_name(door)
46 138
47 def get_room_region_name(self, room_id: int) -> str: 139 def get_room_region_name(self, room_id: int) -> str:
48 room = self.objects.rooms[room_id] 140 room = self.objects.rooms[room_id]
49 return f"{self.objects.maps[room.map_id].name} - {room.name}" 141 return f"{self.get_map_object_map_name(room)} - {room.name}"
142
143 def get_map_object_map_name(self, obj) -> str:
144 return self.objects.maps[obj.map_id].display_name
145
146 def get_room_object_map_name(self, obj) -> str:
147 return self.get_map_object_map_name(self.objects.rooms[obj.room_id])
148
149 def get_room_object_location_prefix(self, obj) -> str:
150 room = self.objects.rooms[obj.room_id]
151 game_map = self.objects.maps[room.map_id]
152
153 if room.HasField("panel_display_name"):
154 return f"{game_map.display_name} ({room.panel_display_name})"
155 else:
156 return game_map.display_name
157
158 def get_data_version(self) -> int:
159 return self.objects.version
diff --git a/apworld/version.py b/apworld/version.py new file mode 100644 index 0000000..87f8797 --- /dev/null +++ b/apworld/version.py
@@ -0,0 +1 @@
APWORLD_VERSION = 2