about summary refs log tree commit diff stats
path: root/apworld
diff options
context:
space:
mode:
Diffstat (limited to 'apworld')
-rw-r--r--apworld/CHANGELOG.md54
-rw-r--r--apworld/README.md48
-rw-r--r--apworld/__init__.py78
-rw-r--r--apworld/docs/en_Lingo_2.md4
-rw-r--r--apworld/items.py31
-rw-r--r--apworld/locations.py2
-rw-r--r--apworld/options.py162
-rw-r--r--apworld/player_logic.py524
-rw-r--r--apworld/regions.py99
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py63
-rw-r--r--apworld/static_logic.py149
-rw-r--r--apworld/version.py1
13 files changed, 1200 insertions, 17 deletions
diff --git a/apworld/CHANGELOG.md b/apworld/CHANGELOG.md new file mode 100644 index 0000000..af45992 --- /dev/null +++ b/apworld/CHANGELOG.md
@@ -0,0 +1,54 @@
1# lingo2-archipelago Apworld Releases
2
3## v5.5 - 2025-09-16
4
5- Fixed a panel in The Ancient that was missing a symbol.
6- Fixed an issue where you could be expected to get S1 in The Darkroom without
7 having U.
8- Renamed a few locations.
9
10Download:
11[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v5.5/lingo2.apworld)<br/>
12Template YAML:
13[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v5.5/Lingo%202.yaml)<br/>
14Source:
15[v5.5](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v5.5)
16
17## v4.4 - 2025-09-14
18
19- Fixed panel set location names.
20
21Download:
22[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.4/lingo2.apworld)<br/>
23Template YAML:
24[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.4/Lingo%202.yaml)<br/>
25Source:
26[v4.4](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v4.4)
27
28## v4.3 - 2025-09-13
29
30- Added a location for the anti-collectable in The Repetitive.
31- Added trap items. These remove letters from your keyboard until you use the
32 Key Return in The Entry, similar to the anti-collectable in The Repetitive.
33 This can be controlled using the `trap_percentage` option, which defaults to
34 zero.
35- Fixed crash on load when using Python 3.11.
36
37Download:
38[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.3/lingo2.apworld)<br/>
39Template YAML:
40[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v4.3/Lingo%202.yaml)<br/>
41Source:
42[v4.3](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v4.3)
43
44## v3.2 - 2025-09-12
45
46- Initial release for testing. Features include door shuffle, letter shuffle,
47 and symbol shuffle.
48
49Download:
50[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/lingo2.apworld)<br/>
51Template YAML:
52[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/Lingo%202.yaml)<br/>
53Source:
54[v3.2](https://code.fourisland.com/lingo2-archipelago/tag/?h=apworld-v3.2)
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 1544c7b..f1de503 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -1,16 +1,27 @@
1""" 1"""
2Archipelago init file for Lingo 2 2Archipelago init file for Lingo 2
3""" 3"""
4from BaseClasses import ItemClassification, Item, Tutorial
4from worlds.AutoWorld import WebWorld, World 5from worlds.AutoWorld import WebWorld, World
6from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS
5from .options import Lingo2Options 7from .options import Lingo2Options
6from .player_logic import Lingo2PlayerLogic 8from .player_logic import Lingo2PlayerLogic
7from .regions import create_regions 9from .regions import create_regions
8from .static_logic import Lingo2StaticLogic 10from .static_logic import Lingo2StaticLogic
11from .version import APWORLD_VERSION
9 12
10 13
11class Lingo2WebWorld(WebWorld): 14class Lingo2WebWorld(WebWorld):
12 rich_text_options_doc = True 15 rich_text_options_doc = True
13 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 )]
14 25
15 26
16class Lingo2World(World): 27class Lingo2World(World):
@@ -22,12 +33,16 @@ class Lingo2World(World):
22 game = "Lingo 2" 33 game = "Lingo 2"
23 web = Lingo2WebWorld() 34 web = Lingo2WebWorld()
24 35
36 topology_present = True
37
25 options_dataclass = Lingo2Options 38 options_dataclass = Lingo2Options
26 options: Lingo2Options 39 options: Lingo2Options
27 40
28 static_logic = Lingo2StaticLogic() 41 static_logic = Lingo2StaticLogic()
29 item_name_to_id = static_logic.item_name_to_id 42 item_name_to_id = static_logic.item_name_to_id
30 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
31 46
32 player_logic: Lingo2PlayerLogic 47 player_logic: Lingo2PlayerLogic
33 48
@@ -36,3 +51,66 @@ class Lingo2World(World):
36 51
37 def create_regions(self): 52 def create_regions(self):
38 create_regions(self) 53 create_regions(self)
54
55 from Utils import visualize_regions
56
57 visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
58
59 def create_items(self):
60 pool = [self.create_item(name) for name in self.player_logic.real_items]
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
66 if self.options.trap_percentage > 0:
67 num_traps = int(item_difference * self.options.trap_percentage / 100)
68 item_difference = item_difference - num_traps
69
70 trap_names = []
71 trap_weights = []
72 for letter_name, weight in self.static_logic.letter_weights.items():
73 trap_names.append(f"Anti {letter_name}")
74 trap_weights.append(weight)
75
76 bad_letters = self.random.choices(trap_names, weights=trap_weights, k=num_traps)
77 pool += [self.create_item(trap_name) for trap_name in bad_letters]
78
79 for i in range(0, item_difference):
80 pool.append(self.create_item(self.get_filler_item_name()))
81
82 self.multiworld.itempool += pool
83
84 def create_item(self, name: str) -> Item:
85 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
86 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else
87 ItemClassification.progression,
88 self.item_name_to_id.get(name), self.player)
89
90 def set_rules(self):
91 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
92
93 def fill_slot_data(self):
94 slot_options = [
95 "cyan_door_behavior",
96 "daedalus_roof_access",
97 "keyholder_sanity",
98 "shuffle_control_center_colors",
99 "shuffle_doors",
100 "shuffle_gallery_paintings",
101 "shuffle_letters",
102 "shuffle_symbols",
103 "strict_cyan_ending",
104 "strict_purple_ending",
105 "victory_condition",
106 ]
107
108 slot_data = {
109 **self.options.as_dict(*slot_options),
110 "version": [self.static_logic.get_data_version(), APWORLD_VERSION],
111 }
112
113 return slot_data
114
115 def get_filler_item_name(self) -> str:
116 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 new file mode 100644 index 0000000..28158c3 --- /dev/null +++ b/apworld/items.py
@@ -0,0 +1,31 @@
1from .generated import data_pb2 as data_pb2
2from BaseClasses import Item
3
4
5class Lingo2Item(Item):
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}
30
31ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
diff --git a/apworld/locations.py b/apworld/locations.py index 818be39..108decb 100644 --- a/apworld/locations.py +++ b/apworld/locations.py
@@ -2,4 +2,4 @@ from BaseClasses import Location
2 2
3 3
4class Lingo2Location(Location): 4class Lingo2Location(Location):
5 game: str = "Lingo 2" \ No newline at end of file 5 game: str = "Lingo 2"
diff --git a/apworld/options.py b/apworld/options.py index f33f5af..3646eea 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,8 +1,166 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range
4
5
6class ShuffleDoors(DefaultOnToggle):
7 """If enabled, most doors will open from receiving an item rather than fulfilling the in-game requirements."""
8 display_name = "Shuffle Doors"
9
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 StrictPurpleEnding(DefaultOnToggle):
96 """
97 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending.
98 Otherwise, some of the letters may be skippable depending on the other options.
99 """
100 display_name = "Strict Purple Ending"
101
102
103class StrictCyanEnding(DefaultOnToggle):
104 """
105 If enabled, the player will be required to have all cyan (level 2) letters in order to get Cyan Ending. Otherwise,
106 at least J2, Q2, and V2 are skippable. Others may also be skippable depending on the options chosen.
107 """
108 display_name = "Strict Cyan Ending"
109
110
111class VictoryCondition(Choice):
112 """
113 This option determines what your goal is.
114
115 - **Gray Ending** (The Colorful)
116 - **Purple Ending** (The Sun Temple). This ordinarily requires all level 1 (purple) letters.
117 - **Mint Ending** (typing EXIT into the keyholders in Control Center)
118 - **Black Ending** (The Graveyard)
119 - **Blue Ending** (The Words)
120 - **Cyan Ending** (The Parthenon). This ordinarily requires almost all level 2 (cyan) letters.
121 - **Red Ending** (The Tower)
122 - **Plum Ending** (The Wondrous / The Door)
123 - **Orange Ending** (the castle in Daedalus)
124 - **Gold Ending** (The Gold). This involves going through the color rooms in Daedalus.
125 - **Yellow Ending** (The Gallery). This requires unlocking all gallery paintings.
126 - **Green Ending** (The Ancient). This requires filling all keyholders with specific letters.
127 - **White Ending** (Control Center). This combines every other ending.
128 """
129 display_name = "Victory Condition"
130 option_gray_ending = 0
131 option_purple_ending = 1
132 option_mint_ending = 2
133 option_black_ending = 3
134 option_blue_ending = 4
135 option_cyan_ending = 5
136 option_red_ending = 6
137 option_plum_ending = 7
138 option_orange_ending = 8
139 option_gold_ending = 9
140 option_yellow_ending = 10
141 option_green_ending = 11
142 option_white_ending = 12
143
144
145class TrapPercentage(Range):
146 """Replaces junk items with traps, at the specified rate."""
147 display_name = "Trap Percentage"
148 range_start = 0
149 range_end = 100
150 default = 0
4 151
5 152
6@dataclass 153@dataclass
7class Lingo2Options(PerGameCommonOptions): 154class Lingo2Options(PerGameCommonOptions):
8 pass 155 shuffle_doors: ShuffleDoors
156 shuffle_control_center_colors: ShuffleControlCenterColors
157 shuffle_gallery_paintings: ShuffleGalleryPaintings
158 shuffle_letters: ShuffleLetters
159 shuffle_symbols: ShuffleSymbols
160 keyholder_sanity: KeyholderSanity
161 cyan_door_behavior: CyanDoorBehavior
162 daedalus_roof_access: DaedalusRoofAccess
163 strict_purple_ending: StrictPurpleEnding
164 strict_cyan_ending: StrictCyanEnding
165 victory_condition: VictoryCondition
166 trap_percentage: TrapPercentage
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 675c6ae..4aa481d 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -1,20 +1,538 @@
1from .generated import common_pb2 as common_pb2 1from enum import IntEnum, auto
2
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
7 12
13def calculate_letter_histogram(solution: str) -> dict[str, int]:
14 histogram = dict()
15 for l in solution:
16 if l.isalpha():
17 real_l = l.upper()
18 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
19
20 return histogram
21
22
23class AccessRequirements:
24 items: set[str]
25 progressives: dict[str, int]
26 rooms: set[str]
27 letters: dict[str, int]
28 cyans: bool
29
30 # This is an AND of ORs.
31 or_logic: list[list["AccessRequirements"]]
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
38 def __init__(self):
39 self.items = set()
40 self.progressives = dict()
41 self.rooms = set()
42 self.letters = dict()
43 self.cyans = False
44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
47
48 def copy(self) -> "AccessRequirements":
49 reqs = AccessRequirements()
50 reqs.items = self.items.copy()
51 reqs.progressives = self.progressives.copy()
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
59
60 def merge(self, other: "AccessRequirements"):
61 for item in other.items:
62 self.items.add(item)
63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
67 for room in other.rooms:
68 self.rooms.add(room)
69
70 for letter, level in other.letters.items():
71 self.letters[letter] = max(self.letters.get(letter, 0), level)
72
73 self.cyans = self.cyans or other.cyans
74
75 for disjunction in other.or_logic:
76 self.or_logic.append(disjunction)
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 new_reqs = sub_reqs.copy()
118 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
119 if new_reqs != sub_reqs:
120 return new_reqs
121 else:
122 return sub_reqs
123
124 self.or_logic = []
125 for disjunction in old_or_logic:
126 new_disjunction = []
127 for ssr in disjunction:
128 new_ssr = remove_redundant(ssr)
129 if not new_ssr.is_empty():
130 new_disjunction.append(new_ssr)
131 else:
132 new_disjunction.clear()
133 break
134 if len(new_disjunction) == 1:
135 self.merge(new_disjunction[0])
136 resimplify = True
137 elif len(new_disjunction) > 1:
138 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
139 self.merge(new_disjunction[0])
140 resimplify = True
141 else:
142 self.or_logic.append(new_disjunction)
143
144 if resimplify:
145 self.simplify()
146
147 def get_referenced_rooms(self):
148 result = set(self.rooms)
149
150 for disjunction in self.or_logic:
151 for sub_req in disjunction:
152 result = result.union(sub_req.get_referenced_rooms())
153
154 for sub_req in self.possibilities:
155 result = result.union(sub_req.get_referenced_rooms())
156
157 return result
158
159 def remove_room(self, room: str):
160 if room in self.rooms:
161 self.rooms.remove(room)
162
163 for disjunction in self.or_logic:
164 for sub_req in disjunction:
165 sub_req.remove_room(room)
166
167 for sub_req in self.possibilities:
168 sub_req.remove_room(room)
169
170 def __repr__(self):
171 parts = []
172 if len(self.items) > 0:
173 parts.append(f"items={self.items}")
174 if len(self.progressives) > 0:
175 parts.append(f"progressives={self.progressives}")
176 if len(self.rooms) > 0:
177 parts.append(f"rooms={self.rooms}")
178 if len(self.letters) > 0:
179 parts.append(f"letters={self.letters}")
180 if self.cyans:
181 parts.append(f"cyans=True")
182 if len(self.or_logic) > 0:
183 parts.append(f"or_logic={self.or_logic}")
184 if self.complete_at is not None:
185 parts.append(f"complete_at={self.complete_at}")
186 if len(self.possibilities) > 0:
187 parts.append(f"possibilities={self.possibilities}")
188 return "AccessRequirements(" + ", ".join(parts) + ")"
189
190
8class PlayerLocation(NamedTuple): 191class PlayerLocation(NamedTuple):
9 code: int | None 192 code: int | None
193 reqs: AccessRequirements
194
195
196class LetterBehavior(IntEnum):
197 VANILLA = auto()
198 ITEM = auto()
199 UNLOCKED = auto()
10 200
11 201
12class Lingo2PlayerLogic: 202class Lingo2PlayerLogic:
203 world: "Lingo2World"
204
13 locations_by_room: dict[int, list[PlayerLocation]] 205 locations_by_room: dict[int, list[PlayerLocation]]
206 event_loc_item_by_room: dict[int, dict[str, str]]
207
208 item_by_door: dict[int, tuple[str, int]]
209
210 panel_reqs: dict[int, AccessRequirements]
211 proxy_reqs: dict[int, dict[str, AccessRequirements]]
212 door_reqs: dict[int, AccessRequirements]
213
214 real_items: list[str]
215
216 double_letter_amount: dict[str, int]
14 217
15 def __init__(self, world: "Lingo2World"): 218 def __init__(self, world: "Lingo2World"):
219 self.world = world
16 self.locations_by_room = {} 220 self.locations_by_room = {}
221 self.event_loc_item_by_room = {}
222 self.item_by_door = {}
223 self.panel_reqs = dict()
224 self.proxy_reqs = dict()
225 self.door_reqs = dict()
226 self.real_items = list()
227 self.double_letter_amount = dict()
228
229 if self.world.options.shuffle_doors:
230 for progressive in world.static_logic.objects.progressives:
231 for i in range(0, len(progressive.doors)):
232 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
233 self.real_items.append(progressive.name)
234
235 for door_group in world.static_logic.objects.door_groups:
236 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
237 if not self.world.options.shuffle_doors:
238 continue
239 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
240 if not self.world.options.shuffle_control_center_colors:
241 continue
242 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
243 if not self.world.options.shuffle_doors:
244 continue
245 else:
246 continue
247
248 for door in door_group.doors:
249 self.item_by_door[door] = (door_group.name, 1)
17 250
251 self.real_items.append(door_group.name)
252
253 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
254 # before we calculate any access requirements.
18 for door in world.static_logic.objects.doors: 255 for door in world.static_logic.objects.doors:
19 if door.type == common_pb2.DoorType.STANDARD: 256 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
20 self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id)) 257 continue
258
259 if door.id in self.item_by_door:
260 continue
261
262 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
263 not self.world.options.shuffle_doors):
264 continue
265
266 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
267 not self.world.options.shuffle_control_center_colors):
268 continue
269
270 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
271 continue
272
273 door_item_name = self.world.static_logic.get_door_item_name(door)
274 self.item_by_door[door.id] = (door_item_name, 1)
275 self.real_items.append(door_item_name)
276
277 # We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle
278 # should be exempt from cyan_door_behavior.
279 if world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
280 for door_group in world.static_logic.objects.door_groups:
281 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
282 continue
283
284 for door in door_group.doors:
285 if not door in self.item_by_door:
286 self.item_by_door[door] = (door_group.name, 1)
287
288 self.real_items.append(door_group.name)
289
290 for door in world.static_logic.objects.doors:
291 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
292 self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id,
293 self.get_door_reqs(door.id)))
294
295 for letter in world.static_logic.objects.letters:
296 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
297 AccessRequirements()))
298 behavior = self.get_letter_behavior(letter.key, letter.level2)
299 if behavior == LetterBehavior.VANILLA:
300 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
301 event_name = f"{letter_name} (Collected)"
302 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
303
304 if letter.level2:
305 event_name = f"{letter_name} (Double Collected)"
306 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
307 elif behavior == LetterBehavior.ITEM:
308 self.real_items.append(letter.key.upper())
309
310 if behavior != LetterBehavior.UNLOCKED:
311 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
312
313 for mastery in world.static_logic.objects.masteries:
314 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
315 AccessRequirements()))
316
317 for ending in world.static_logic.objects.endings:
318 # Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory
319 # condition, since it is necessarily going to be in the postgame.
320 if ending.name == "WHITE":
321 if self.world.options.victory_condition != VictoryCondition.option_white_ending:
322 continue
323 else:
324 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id,
325 AccessRequirements()))
326
327 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
328 item_name = event_name
329
330 if world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
331 item_name = "Victory"
332
333 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
334
335 if self.world.options.keyholder_sanity:
336 for keyholder in world.static_logic.objects.keyholders:
337 if keyholder.HasField("key"):
338 reqs = AccessRequirements()
339
340 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
341 reqs.letters[keyholder.key.upper()] = 1
342
343 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
344 reqs))
345
346 if self.world.options.shuffle_symbols:
347 for symbol_name in SYMBOL_ITEMS.values():
348 self.real_items.append(symbol_name)
349
350 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
351 if answer is None:
352 if panel_id not in self.panel_reqs:
353 self.panel_reqs[panel_id] = self.calculate_panel_reqs(panel_id, answer)
354
355 return self.panel_reqs.get(panel_id)
356 else:
357 if panel_id not in self.proxy_reqs or answer not in self.proxy_reqs.get(panel_id):
358 self.proxy_reqs.setdefault(panel_id, {})[answer] = self.calculate_panel_reqs(panel_id, answer)
359
360 return self.proxy_reqs.get(panel_id).get(answer)
361
362 def calculate_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
363 panel = self.world.static_logic.objects.panels[panel_id]
364 reqs = AccessRequirements()
365
366 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
367
368 if answer is not None:
369 self.add_solution_reqs(reqs, answer)
370 elif len(panel.proxies) > 0:
371 possibilities = []
372 already_filled = False
373
374 for proxy in panel.proxies:
375 proxy_reqs = AccessRequirements()
376 self.add_solution_reqs(proxy_reqs, proxy.answer)
377
378 if not proxy_reqs.is_empty():
379 possibilities.append(proxy_reqs)
380 else:
381 already_filled = True
382 break
383
384 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
385 proxy_reqs = AccessRequirements()
386 self.add_solution_reqs(proxy_reqs, panel.answer)
387
388 if not proxy_reqs.is_empty():
389 possibilities.append(proxy_reqs)
390 else:
391 already_filled = True
392
393 if not already_filled:
394 reqs.or_logic.append(possibilities)
395 else:
396 self.add_solution_reqs(reqs, panel.answer)
397
398 if self.world.options.shuffle_symbols:
399 for symbol in panel.symbols:
400 reqs.items.add(SYMBOL_ITEMS.get(symbol))
401
402 if panel.HasField("required_door"):
403 door_reqs = self.get_door_open_reqs(panel.required_door)
404 reqs.merge(door_reqs)
405
406 if panel.HasField("required_room"):
407 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.required_room))
408
409 return reqs
410
411 # This gets/calculates the requirements described by the door object. This is most notably used as the requirements
412 # for clearing a location, or opening a door when the door is not shuffled.
413 def get_door_reqs(self, door_id: int) -> AccessRequirements:
414 if door_id not in self.door_reqs:
415 self.door_reqs[door_id] = self.calculate_door_reqs(door_id)
416
417 return self.door_reqs.get(door_id)
418
419 def calculate_door_reqs(self, door_id: int) -> AccessRequirements:
420 door = self.world.static_logic.objects.doors[door_id]
421 reqs = AccessRequirements()
422
423 if not door.HasField("complete_at") or door.complete_at == 0:
424 for proxy in door.panels:
425 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
426 reqs.merge(panel_reqs)
427 elif door.complete_at == 1:
428 disjunction = []
429 for proxy in door.panels:
430 proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
431 if proxy_reqs.is_empty():
432 disjunction.clear()
433 break
434 else:
435 disjunction.append(proxy_reqs)
436 if len(disjunction) > 0:
437 reqs.or_logic.append(disjunction)
438 else:
439 reqs.complete_at = door.complete_at
440 for proxy in door.panels:
441 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
442 reqs.possibilities.append(panel_reqs)
443
444 if door.HasField("control_center_color"):
445 # TODO: Logic for ensuring two CC states aren't needed at once.
446 reqs.rooms.add("Control Center - Main Area")
447 self.add_solution_reqs(reqs, door.control_center_color)
448
449 if door.double_letters:
450 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
451 reqs.rooms.add("The Repetitive - Main Room")
452 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
453 if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
454 reqs.cyans = True
455 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
456 # There shouldn't be any locations that are cyan doors.
457 pass
458
459 for keyholder_uses in door.keyholders:
460 key_name = keyholder_uses.key.upper()
461 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
462 and key_name not in reqs.letters):
463 reqs.letters[key_name] = 1
464
465 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
466 reqs.rooms.add(self.world.static_logic.get_room_region_name(keyholder.room_id))
467
468 for room in door.rooms:
469 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
470
471 for ending_id in door.endings:
472 ending = self.world.static_logic.objects.endings[ending_id]
473
474 if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
475 reqs.items.add("Victory")
476 else:
477 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
478
479 for sub_door_id in door.doors:
480 sub_reqs = self.get_door_open_reqs(sub_door_id)
481 reqs.merge(sub_reqs)
482
483 reqs.simplify()
484
485 return reqs
486
487 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
488 # that acts as the door's key.
489 def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
490 if door_id in self.item_by_door:
491 reqs = AccessRequirements()
492
493 item_name, amount = self.item_by_door.get(door_id)
494 if amount == 1:
495 reqs.items.add(item_name)
496 else:
497 reqs.progressives[item_name] = amount
498
499 return reqs
500 else:
501 return self.get_door_reqs(door_id)
502
503 def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
504 if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
505 return LetterBehavior.UNLOCKED
506
507 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
508 if level2:
509 if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
510 return LetterBehavior.VANILLA
511 else:
512 return LetterBehavior.ITEM
513 else:
514 return LetterBehavior.UNLOCKED
515
516 if not level2 and letter in ["h", "i", "n", "t"]:
517 return LetterBehavior.UNLOCKED
518
519 if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
520 return LetterBehavior.ITEM
521
522 return LetterBehavior.VANILLA
523
524 def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
525 histogram = calculate_letter_histogram(solution)
526
527 for l, a in histogram.items():
528 needed = min(a, 2)
529 level2 = (needed == 2)
530
531 if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
532 needed = 1
533
534 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
535 needed = needed - 1
536
537 if needed > 0:
538 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
diff --git a/apworld/regions.py b/apworld/regions.py index d388678..993eec8 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -1,29 +1,118 @@
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
6from .player_logic import AccessRequirements
7from .rules import make_location_lambda
5 8
6if TYPE_CHECKING: 9if TYPE_CHECKING:
7 from . import Lingo2World 10 from . import Lingo2World
8 11
9 12
10def create_region(room, world: "Lingo2World") -> Region: 13def create_region(room, world: "Lingo2World") -> Region:
11 new_region = Region(room.name, world.player, world.multiworld) 14 return Region(world.static_logic.get_room_region_name(room.id), world.player, world.multiworld)
12 15
16
17def create_locations(room, new_region: Region, world: "Lingo2World", regions: dict[str, Region]):
13 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 reqs.remove_room(new_region.name)
21
14 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 22 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code],
15 location.code, new_region) 23 location.code, new_region)
24 new_location.access_rule = make_location_lambda(reqs, world, regions)
16 new_region.locations.append(new_location) 25 new_region.locations.append(new_location)
17 26
18 return new_region 27 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
19 28 new_location = Lingo2Location(world.player, event_name, None, new_region)
29 event_item = Lingo2Item(item_name, ItemClassification.progression, None, world.player)
30 new_location.place_locked_item(event_item)
31 new_region.locations.append(new_location)
20 32
21def create_regions(world: "Lingo2World"): 33def create_regions(world: "Lingo2World"):
22 regions = { 34 regions = {
23 "Menu": Region("Menu", world.player, world.multiworld) 35 "Menu": Region("Menu", world.player, world.multiworld)
24 } 36 }
25 37
38 region_and_room = []
39
40 # Create the regions in two stages. First, make the actual region objects and memoize them. Then, add all of the
41 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is
42 # faster than having to look them up during access checking.
26 for room in world.static_logic.objects.rooms: 43 for room in world.static_logic.objects.rooms:
27 regions[room.name] = create_region(room, world) 44 region = create_region(room, world)
45 regions[region.name] = region
46 region_and_room.append((region, room))
47
48 for (region, room) in region_and_room:
49 create_locations(room, region, world, regions)
50
51 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
52
53 # TODO: The requirements of the opposite trigger also matter.
54 for connection in world.static_logic.objects.connections:
55 if connection.roof_access and not world.options.daedalus_roof_access:
56 continue
57
58 from_region = world.static_logic.get_room_region_name(connection.from_room)
59 to_region = world.static_logic.get_room_region_name(connection.to_room)
60
61 if from_region not in regions or to_region not in regions:
62 continue
63
64 connection_name = f"{from_region} -> {to_region}"
65
66 reqs = AccessRequirements()
67
68 if connection.HasField("required_door"):
69 reqs.merge(world.player_logic.get_door_open_reqs(connection.required_door))
70
71 door = world.static_logic.objects.doors[connection.required_door]
72 wmap = world.static_logic.objects.maps[door.map_id]
73 connection_name = f"{connection_name} (using {wmap.name} - {door.name})"
74
75 if connection.HasField("port"):
76 port = world.static_logic.objects.ports[connection.port]
77 connection_name = f"{connection_name} (via port {port.name})"
78
79 if port.HasField("required_door"):
80 reqs.merge(world.player_logic.get_door_open_reqs(port.required_door))
81
82 if connection.HasField("painting"):
83 painting = world.static_logic.objects.paintings[connection.painting]
84 connection_name = f"{connection_name} (via painting {painting.name})"
85
86 if painting.HasField("required_door"):
87 reqs.merge(world.player_logic.get_door_open_reqs(painting.required_door))
88
89 if connection.HasField("panel"):
90 proxy = connection.panel
91 reqs.merge(world.player_logic.get_panel_reqs(proxy.panel,
92 proxy.answer if proxy.HasField("answer") else None))
93
94 panel = world.static_logic.objects.panels[proxy.panel]
95 if proxy.HasField("answer"):
96 connection_name = f"{connection_name} (via panel {panel.name}/{proxy.answer})"
97 else:
98 connection_name = f"{connection_name} (via panel {panel.name})"
99
100 if connection.HasField("purple_ending") and connection.purple_ending and world.options.strict_purple_ending:
101 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyz")
102
103 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending:
104 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz")
105
106 reqs.simplify()
107 reqs.remove_room(from_region)
108
109 connection = Entrance(world.player, connection_name, regions[from_region])
110 connection.access_rule = make_location_lambda(reqs, world, regions)
111
112 regions[from_region].exits.append(connection)
113 connection.connect(regions[to_region])
114
115 for region in reqs.get_referenced_rooms():
116 world.multiworld.register_indirect_condition(regions[region], connection)
28 117
29 world.multiworld.regions += regions.values() 118 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 new file mode 100644 index 0000000..c077858 --- /dev/null +++ b/apworld/rules.py
@@ -0,0 +1,63 @@
1from collections.abc import Callable
2from typing import TYPE_CHECKING
3
4from BaseClasses import CollectionState, Region
5from .player_logic import AccessRequirements
6
7if TYPE_CHECKING:
8 from . import Lingo2World
9
10
11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region],
12 world: "Lingo2World") -> bool:
13 if not all(state.has(item, world.player) for item in reqs.items):
14 return False
15
16 if not all(state.has(item, world.player, amount) for item, amount in reqs.progressives.items()):
17 return False
18
19 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms):
20 return False
21
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
38
39 if reqs.complete_at is not None:
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:
52 return False
53
54 return True
55
56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
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 d3ed85c..e4d7d49 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py
@@ -1,5 +1,5 @@
1from .generated import common_pb2 as common_pb2
2from .generated import data_pb2 as data_pb2 1from .generated import data_pb2 as data_pb2
2from .items import SYMBOL_ITEMS, ANTI_COLLECTABLE_TRAPS
3import pkgutil 3import pkgutil
4 4
5class Lingo2StaticLogic: 5class Lingo2StaticLogic:
@@ -9,22 +9,161 @@ class Lingo2StaticLogic:
9 item_name_to_id: dict[str, int] 9 item_name_to_id: dict[str, int]
10 location_name_to_id: dict[str, int] 10 location_name_to_id: dict[str, int]
11 11
12 item_name_groups: dict[str, list[str]]
13 location_name_groups: dict[str, list[str]]
14
15 letter_weights: dict[str, int]
16
12 def __init__(self): 17 def __init__(self):
13 self.item_id_to_name = {} 18 self.item_id_to_name = {}
14 self.location_id_to_name = {} 19 self.location_id_to_name = {}
20 self.item_name_groups = {}
21 self.location_name_groups = {}
22 self.letter_weights = {}
15 23
16 file = pkgutil.get_data(__name__, "generated/data.binpb") 24 file = pkgutil.get_data(__name__, "generated/data.binpb")
17 self.objects = data_pb2.AllObjects() 25 self.objects = data_pb2.AllObjects()
18 self.objects.ParseFromString(bytearray(file)) 26 self.objects.ParseFromString(bytearray(file))
19 27
20 for door in self.objects.doors: 28 for door in self.objects.doors:
21 if door.type == common_pb2.DoorType.STANDARD: 29 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
22 location_name = f"{self.objects.rooms[door.room_id].display_name} - {door.name}" 30 location_name = self.get_door_location_name(door)
23 self.location_id_to_name[door.ap_id] = location_name 31 self.location_id_to_name[door.ap_id] = location_name
24 32
25 if door.type != common_pb2.DoorType.EVENT: 33 if door.type not in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
26 item_name = f"{self.objects.rooms[door.room_id].display_name} - {door.name}" 34 item_name = self.get_door_item_name(door)
27 self.item_id_to_name[door.ap_id] = item_name 35 self.item_id_to_name[door.ap_id] = item_name
28 36
37 for letter in self.objects.letters:
38 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
39 location_name = f"{self.get_room_object_map_name(letter)} - {letter_name}"
40 self.location_id_to_name[letter.ap_id] = location_name
41 self.location_name_groups.setdefault("Letters", []).append(location_name)
42
43 if not letter.level2:
44 self.item_id_to_name[letter.ap_id] = letter.key.upper()
45 self.item_name_groups.setdefault("Letters", []).append(letter.key.upper())
46
47 for mastery in self.objects.masteries:
48 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery"
49 self.location_id_to_name[mastery.ap_id] = location_name
50 self.location_name_groups.setdefault("Masteries", []).append(location_name)
51
52 for ending in self.objects.endings:
53 location_name = f"{self.get_room_object_map_name(ending)} - {ending.name.title()} Ending"
54 self.location_id_to_name[ending.ap_id] = location_name
55 self.location_name_groups.setdefault("Endings", []).append(location_name)
56
57 for progressive in self.objects.progressives:
58 self.item_id_to_name[progressive.ap_id] = progressive.name
59
60 for door_group in self.objects.door_groups:
61 self.item_id_to_name[door_group.ap_id] = door_group.name
62
63 for keyholder in self.objects.keyholders:
64 if keyholder.HasField("key"):
65 location_name = f"{self.get_room_object_location_prefix(keyholder)} - {keyholder.key.upper()} Keyholder"
66 self.location_id_to_name[keyholder.ap_id] = location_name
67 self.location_name_groups.setdefault("Keyholders", []).append(location_name)
68
69 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done"
70
71 for symbol_name in SYMBOL_ITEMS.values():
72 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name
73
74 for trap_name in ANTI_COLLECTABLE_TRAPS:
75 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name
76
29 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} 77 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()}
30 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} 78 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()}
79
80 for panel in self.objects.panels:
81 for letter in panel.answer.upper():
82 self.letter_weights[letter] = self.letter_weights.get(letter, 0) + 1
83
84 def get_door_item_name(self, door: data_pb2.Door) -> str:
85 return f"{self.get_map_object_map_name(door)} - {door.name}"
86
87 def get_door_item_name_by_id(self, door_id: int) -> str:
88 door = self.objects.doors[door_id]
89 return self.get_door_item_name(door_id)
90
91 def get_door_location_name(self, door: data_pb2.Door) -> str:
92 map_part = self.get_room_object_location_prefix(door)
93
94 if door.HasField("location_name"):
95 return f"{map_part} - {door.location_name}"
96
97 generated_location_name = self.get_generated_door_location_name(door)
98 if generated_location_name is not None:
99 return generated_location_name
100
101 return f"{map_part} - {door.name}"
102
103 def get_generated_door_location_name(self, door: data_pb2.Door) -> str | None:
104 if door.type != data_pb2.DoorType.STANDARD:
105 return None
106
107 if len(door.keyholders) > 0 or len(door.endings) > 0 or door.HasField("complete_at"):
108 return None
109
110 if len(door.panels) > 4:
111 return None
112
113 map_areas = set()
114 for panel_id in door.panels:
115 panel = self.objects.panels[panel_id.panel]
116 panel_room = self.objects.rooms[panel.room_id]
117 # It's okay if panel_display_name is not present because then it's coalesced with other unnamed areas.
118 map_areas.add(panel_room.panel_display_name)
119
120 if len(map_areas) > 1:
121 return None
122
123 game_map = self.objects.maps[door.map_id]
124 map_area = map_areas.pop()
125 if map_area == "":
126 map_part = game_map.display_name
127 else:
128 map_part = f"{game_map.display_name} ({map_area})"
129
130 def get_panel_display_name(panel: data_pb2.ProxyIdentifier) -> str:
131 panel_data = self.objects.panels[panel.panel]
132 panel_name = panel_data.display_name if panel_data.HasField("display_name") else panel_data.name
133
134 if panel.HasField("answer"):
135 return f"{panel_name}/{panel.answer.upper()}"
136 else:
137 return panel_name
138
139 panel_names = [get_panel_display_name(panel_id)
140 for panel_id in door.panels]
141 panel_names.sort()
142
143 return map_part + " - " + ", ".join(panel_names)
144
145 def get_door_location_name_by_id(self, door_id: int) -> str:
146 door = self.objects.doors[door_id]
147 return self.get_door_location_name(door)
148
149 def get_room_region_name(self, room_id: int) -> str:
150 room = self.objects.rooms[room_id]
151 return f"{self.get_map_object_map_name(room)} - {room.name}"
152
153 def get_map_object_map_name(self, obj) -> str:
154 return self.objects.maps[obj.map_id].display_name
155
156 def get_room_object_map_name(self, obj) -> str:
157 return self.get_map_object_map_name(self.objects.rooms[obj.room_id])
158
159 def get_room_object_location_prefix(self, obj) -> str:
160 room = self.objects.rooms[obj.room_id]
161 game_map = self.objects.maps[room.map_id]
162
163 if room.HasField("panel_display_name"):
164 return f"{game_map.display_name} ({room.panel_display_name})"
165 else:
166 return game_map.display_name
167
168 def get_data_version(self) -> int:
169 return self.objects.version
diff --git a/apworld/version.py b/apworld/version.py new file mode 100644 index 0000000..ac799cd --- /dev/null +++ b/apworld/version.py
@@ -0,0 +1 @@
APWORLD_VERSION = 6