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__.py60
-rw-r--r--apworld/docs/en_Lingo_2.md4
-rw-r--r--apworld/items.py26
-rw-r--r--apworld/options.py140
-rw-r--r--apworld/player_logic.py387
-rw-r--r--apworld/regions.py50
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py43
-rw-r--r--apworld/static_logic.py120
-rw-r--r--apworld/version.py1
12 files changed, 855 insertions, 80 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 14bb4bc..f1de503 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, ANTI_COLLECTABLE_TRAPS
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):
@@ -32,6 +41,8 @@ class Lingo2World(World):
32 static_logic = Lingo2StaticLogic() 41 static_logic = Lingo2StaticLogic()
33 item_name_to_id = static_logic.item_name_to_id 42 item_name_to_id = static_logic.item_name_to_id
34 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
35 46
36 player_logic: Lingo2PlayerLogic 47 player_logic: Lingo2PlayerLogic
37 48
@@ -51,14 +62,55 @@ class Lingo2World(World):
51 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values()) 62 total_locations = sum(len(locs) for locs in self.player_logic.locations_by_room.values())
52 63
53 item_difference = total_locations - len(pool) 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
54 for i in range(0, item_difference): 79 for i in range(0, item_difference):
55 pool.append(self.create_item("Nothing")) 80 pool.append(self.create_item(self.get_filler_item_name()))
56 81
57 self.multiworld.itempool += pool 82 self.multiworld.itempool += pool
58 83
59 def create_item(self, name: str) -> Item: 84 def create_item(self, name: str) -> Item:
60 return Lingo2Item(name, ItemClassification.filler if name == "Nothing" else ItemClassification.progression, 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,
61 self.item_name_to_id.get(name), self.player) 88 self.item_name_to_id.get(name), self.player)
62 89
63 def set_rules(self): 90 def set_rules(self):
64 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) 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 index 971a709..28158c3 100644 --- a/apworld/items.py +++ b/apworld/items.py
@@ -1,5 +1,31 @@
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}
30
31ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
diff --git a/apworld/options.py b/apworld/options.py index d984beb..3646eea 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,15 +1,131 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle, Choice 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range
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 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
11class VictoryCondition(Choice): 111class VictoryCondition(Choice):
12 """Victory condition.""" 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 """
13 display_name = "Victory Condition" 129 display_name = "Victory Condition"
14 option_gray_ending = 0 130 option_gray_ending = 0
15 option_purple_ending = 1 131 option_purple_ending = 1
@@ -26,7 +142,25 @@ class VictoryCondition(Choice):
26 option_white_ending = 12 142 option_white_ending = 12
27 143
28 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
151
152
29@dataclass 153@dataclass
30class Lingo2Options(PerGameCommonOptions): 154class Lingo2Options(PerGameCommonOptions):
31 shuffle_doors: ShuffleDoors 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
32 victory_condition: VictoryCondition 165 victory_condition: VictoryCondition
166 trap_percentage: TrapPercentage
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 6feef99..4aa481d 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
@@ -12,64 +17,175 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
12 real_l = l.upper() 17 real_l = l.upper()
13 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2) 18 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
14 19
15 for free_letter in "HINT":
16 if histogram.get(free_letter, 0) == 1:
17 del histogram[free_letter]
18
19 return histogram 20 return histogram
20 21
21 22
22class AccessRequirements: 23class AccessRequirements:
23 items: set[str] 24 items: set[str]
25 progressives: dict[str, int]
24 rooms: set[str] 26 rooms: set[str]
25 symbols: set[str]
26 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool
27 29
28 # This is an AND of ORs. 30 # This is an AND of ORs.
29 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
30 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
31 def __init__(self): 38 def __init__(self):
32 self.items = set() 39 self.items = set()
40 self.progressives = dict()
33 self.rooms = set() 41 self.rooms = set()
34 self.symbols = set()
35 self.letters = dict() 42 self.letters = dict()
43 self.cyans = False
36 self.or_logic = list() 44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
37 47
38 def add_solution(self, solution: str): 48 def copy(self) -> "AccessRequirements":
39 histogram = calculate_letter_histogram(solution) 49 reqs = AccessRequirements()
40 50 reqs.items = self.items.copy()
41 for l, a in histogram.items(): 51 reqs.progressives = self.progressives.copy()
42 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
43 59
44 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
45 for item in other.items: 61 for item in other.items:
46 self.items.add(item) 62 self.items.add(item)
47 63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
48 for room in other.rooms: 67 for room in other.rooms:
49 self.rooms.add(room) 68 self.rooms.add(room)
50 69
51 for symbol in other.symbols:
52 self.symbols.add(symbol)
53
54 for letter, level in other.letters.items(): 70 for letter, level in other.letters.items():
55 self.letters[letter] = max(self.letters.get(letter, 0), level) 71 self.letters[letter] = max(self.letters.get(letter, 0), level)
56 72
73 self.cyans = self.cyans or other.cyans
74
57 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
58 self.or_logic.append(disjunction) 76 self.or_logic.append(disjunction)
59 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
60 def __repr__(self): 170 def __repr__(self):
61 parts = [] 171 parts = []
62 if len(self.items) > 0: 172 if len(self.items) > 0:
63 parts.append(f"items={self.items}") 173 parts.append(f"items={self.items}")
174 if len(self.progressives) > 0:
175 parts.append(f"progressives={self.progressives}")
64 if len(self.rooms) > 0: 176 if len(self.rooms) > 0:
65 parts.append(f"rooms={self.rooms}") 177 parts.append(f"rooms={self.rooms}")
66 if len(self.symbols) > 0:
67 parts.append(f"symbols={self.symbols}")
68 if len(self.letters) > 0: 178 if len(self.letters) > 0:
69 parts.append(f"letters={self.letters}") 179 parts.append(f"letters={self.letters}")
180 if self.cyans:
181 parts.append(f"cyans=True")
70 if len(self.or_logic) > 0: 182 if len(self.or_logic) > 0:
71 parts.append(f"or_logic={self.or_logic}") 183 parts.append(f"or_logic={self.or_logic}")
72 return f"AccessRequirements({", ".join(parts)})" 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) + ")"
73 189
74 190
75class PlayerLocation(NamedTuple): 191class PlayerLocation(NamedTuple):
@@ -77,13 +193,19 @@ class PlayerLocation(NamedTuple):
77 reqs: AccessRequirements 193 reqs: AccessRequirements
78 194
79 195
196class LetterBehavior(IntEnum):
197 VANILLA = auto()
198 ITEM = auto()
199 UNLOCKED = auto()
200
201
80class Lingo2PlayerLogic: 202class Lingo2PlayerLogic:
81 world: "Lingo2World" 203 world: "Lingo2World"
82 204
83 locations_by_room: dict[int, list[PlayerLocation]] 205 locations_by_room: dict[int, list[PlayerLocation]]
84 event_loc_item_by_room: dict[int, dict[str, str]] 206 event_loc_item_by_room: dict[int, dict[str, str]]
85 207
86 item_by_door: dict[int, str] 208 item_by_door: dict[int, tuple[str, int]]
87 209
88 panel_reqs: dict[int, AccessRequirements] 210 panel_reqs: dict[int, AccessRequirements]
89 proxy_reqs: dict[int, dict[str, AccessRequirements]] 211 proxy_reqs: dict[int, dict[str, AccessRequirements]]
@@ -91,6 +213,8 @@ class Lingo2PlayerLogic:
91 213
92 real_items: list[str] 214 real_items: list[str]
93 215
216 double_letter_amount: dict[str, int]
217
94 def __init__(self, world: "Lingo2World"): 218 def __init__(self, world: "Lingo2World"):
95 self.world = world 219 self.world = world
96 self.locations_by_room = {} 220 self.locations_by_room = {}
@@ -100,14 +224,68 @@ class Lingo2PlayerLogic:
100 self.proxy_reqs = dict() 224 self.proxy_reqs = dict()
101 self.door_reqs = dict() 225 self.door_reqs = dict()
102 self.real_items = list() 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)
250
251 self.real_items.append(door_group.name)
103 252
104 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 253 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
105 # before we calculate any access requirements. 254 # before we calculate any access requirements.
106 for door in world.static_logic.objects.doors: 255 for door in world.static_logic.objects.doors:
107 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: 256 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
108 door_item_name = self.world.static_logic.get_door_item_name(door.id) 257 continue
109 self.item_by_door[door.id] = door_item_name 258
110 self.real_items.append(door_item_name) 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)
111 289
112 for door in world.static_logic.objects.doors: 290 for door in world.static_logic.objects.doors:
113 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 291 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
@@ -117,22 +295,34 @@ class Lingo2PlayerLogic:
117 for letter in world.static_logic.objects.letters: 295 for letter in world.static_logic.objects.letters:
118 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 296 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
119 AccessRequirements())) 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()
120 303
121 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 304 if letter.level2:
122 event_name = f"{letter_name} (Collected)" 305 event_name = f"{letter_name} (Double Collected)"
123 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 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())
124 309
125 if letter.level2: 310 if behavior != LetterBehavior.UNLOCKED:
126 event_name = f"{letter_name} (Double Collected)" 311 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
127 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
128 312
129 for mastery in world.static_logic.objects.masteries: 313 for mastery in world.static_logic.objects.masteries:
130 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 314 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
131 AccessRequirements())) 315 AccessRequirements()))
132 316
133 for ending in world.static_logic.objects.endings: 317 for ending in world.static_logic.objects.endings:
134 self.locations_by_room.setdefault(ending.room_id, []).append(PlayerLocation(ending.ap_id, 318 # Don't ever create a location for White Ending. Don't even make an event for it if it's not the victory
135 AccessRequirements())) 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()))
136 326
137 event_name = f"{ending.name.capitalize()} Ending (Achieved)" 327 event_name = f"{ending.name.capitalize()} Ending (Achieved)"
138 item_name = event_name 328 item_name = event_name
@@ -142,6 +332,21 @@ class Lingo2PlayerLogic:
142 332
143 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name 333 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
144 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
145 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 350 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
146 if answer is None: 351 if answer is None:
147 if panel_id not in self.panel_reqs: 352 if panel_id not in self.panel_reqs:
@@ -161,28 +366,38 @@ class Lingo2PlayerLogic:
161 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) 366 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
162 367
163 if answer is not None: 368 if answer is not None:
164 reqs.add_solution(answer) 369 self.add_solution_reqs(reqs, answer)
165 elif len(panel.proxies) > 0: 370 elif len(panel.proxies) > 0:
166 possibilities = [] 371 possibilities = []
372 already_filled = False
167 373
168 for proxy in panel.proxies: 374 for proxy in panel.proxies:
169 proxy_reqs = AccessRequirements() 375 proxy_reqs = AccessRequirements()
170 proxy_reqs.add_solution(proxy.answer) 376 self.add_solution_reqs(proxy_reqs, proxy.answer)
171 377
172 possibilities.append(proxy_reqs) 378 if not proxy_reqs.is_empty():
379 possibilities.append(proxy_reqs)
380 else:
381 already_filled = True
382 break
173 383
174 if not any(proxy.answer == panel.answer for proxy in panel.proxies): 384 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
175 proxy_reqs = AccessRequirements() 385 proxy_reqs = AccessRequirements()
176 proxy_reqs.add_solution(panel.answer) 386 self.add_solution_reqs(proxy_reqs, panel.answer)
177 387
178 possibilities.append(proxy_reqs) 388 if not proxy_reqs.is_empty():
389 possibilities.append(proxy_reqs)
390 else:
391 already_filled = True
179 392
180 reqs.or_logic.append(possibilities) 393 if not already_filled:
394 reqs.or_logic.append(possibilities)
181 else: 395 else:
182 reqs.add_solution(panel.answer) 396 self.add_solution_reqs(reqs, panel.answer)
183 397
184 for symbol in panel.symbols: 398 if self.world.options.shuffle_symbols:
185 reqs.symbols.add(symbol) 399 for symbol in panel.symbols:
400 reqs.items.add(SYMBOL_ITEMS.get(symbol))
186 401
187 if panel.HasField("required_door"): 402 if panel.HasField("required_door"):
188 door_reqs = self.get_door_open_reqs(panel.required_door) 403 door_reqs = self.get_door_open_reqs(panel.required_door)
@@ -205,22 +420,46 @@ class Lingo2PlayerLogic:
205 door = self.world.static_logic.objects.doors[door_id] 420 door = self.world.static_logic.objects.doors[door_id]
206 reqs = AccessRequirements() 421 reqs = AccessRequirements()
207 422
208 # TODO: control_center_color, switches
209 if not door.HasField("complete_at") or door.complete_at == 0: 423 if not door.HasField("complete_at") or door.complete_at == 0:
210 for proxy in door.panels: 424 for proxy in door.panels:
211 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 425 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
212 reqs.merge(panel_reqs) 426 reqs.merge(panel_reqs)
213 elif door.complete_at == 1: 427 elif door.complete_at == 1:
214 reqs.or_logic.append([self.get_panel_reqs(proxy.panel, 428 disjunction = []
215 proxy.answer if proxy.HasField("answer") else None) 429 for proxy in door.panels:
216 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)
217 else: 438 else:
218 # TODO: Handle complete_at > 1 439 reqs.complete_at = door.complete_at
219 pass 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
220 458
221 for keyholder_uses in door.keyholders: 459 for keyholder_uses in door.keyholders:
222 key_name = keyholder_uses.key.upper() 460 key_name = keyholder_uses.key.upper()
223 if key_name not in reqs.letters: 461 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
462 and key_name not in reqs.letters):
224 reqs.letters[key_name] = 1 463 reqs.letters[key_name] = 1
225 464
226 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder] 465 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
@@ -229,10 +468,20 @@ class Lingo2PlayerLogic:
229 for room in door.rooms: 468 for room in door.rooms:
230 reqs.rooms.add(self.world.static_logic.get_room_region_name(room)) 469 reqs.rooms.add(self.world.static_logic.get_room_region_name(room))
231 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
232 for sub_door_id in door.doors: 479 for sub_door_id in door.doors:
233 sub_reqs = self.get_door_open_reqs(sub_door_id) 480 sub_reqs = self.get_door_open_reqs(sub_door_id)
234 reqs.merge(sub_reqs) 481 reqs.merge(sub_reqs)
235 482
483 reqs.simplify()
484
236 return reqs 485 return reqs
237 486
238 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item 487 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
@@ -240,8 +489,50 @@ class Lingo2PlayerLogic:
240 def get_door_open_reqs(self, door_id: int) -> AccessRequirements: 489 def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
241 if door_id in self.item_by_door: 490 if door_id in self.item_by_door:
242 reqs = AccessRequirements() 491 reqs = AccessRequirements()
243 reqs.items.add(self.item_by_door.get(door_id)) 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
244 498
245 return reqs 499 return reqs
246 else: 500 else:
247 return self.get_door_reqs(door_id) 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 fe2c99b..993eec8 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -11,12 +11,17 @@ if TYPE_CHECKING:
11 11
12 12
13def create_region(room, world: "Lingo2World") -> Region: 13def create_region(room, world: "Lingo2World") -> Region:
14 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)
15 15
16
17def create_locations(room, new_region: Region, world: "Lingo2World", regions: dict[str, Region]):
16 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
17 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],
18 location.code, new_region) 23 location.code, new_region)
19 new_location.access_rule = make_location_lambda(location.reqs, world) 24 new_location.access_rule = make_location_lambda(reqs, world, regions)
20 new_region.locations.append(new_location) 25 new_region.locations.append(new_location)
21 26
22 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): 27 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
@@ -25,24 +30,37 @@ def create_region(room, world: "Lingo2World") -> Region:
25 new_location.place_locked_item(event_item) 30 new_location.place_locked_item(event_item)
26 new_region.locations.append(new_location) 31 new_region.locations.append(new_location)
27 32
28 return new_region
29
30
31def create_regions(world: "Lingo2World"): 33def create_regions(world: "Lingo2World"):
32 regions = { 34 regions = {
33 "Menu": Region("Menu", world.player, world.multiworld) 35 "Menu": Region("Menu", world.player, world.multiworld)
34 } 36 }
35 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.
36 for room in world.static_logic.objects.rooms: 43 for room in world.static_logic.objects.rooms:
37 region = create_region(room, world) 44 region = create_region(room, world)
38 regions[region.name] = region 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)
39 50
40 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 51 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
41 52
42 # TODO: The requirements of the opposite trigger also matter. 53 # TODO: The requirements of the opposite trigger also matter.
43 for connection in world.static_logic.objects.connections: 54 for connection in world.static_logic.objects.connections:
55 if connection.roof_access and not world.options.daedalus_roof_access:
56 continue
57
44 from_region = world.static_logic.get_room_region_name(connection.from_room) 58 from_region = world.static_logic.get_room_region_name(connection.from_room)
45 to_region = world.static_logic.get_room_region_name(connection.to_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
46 connection_name = f"{from_region} -> {to_region}" 64 connection_name = f"{from_region} -> {to_region}"
47 65
48 reqs = AccessRequirements() 66 reqs = AccessRequirements()
@@ -79,14 +97,22 @@ def create_regions(world: "Lingo2World"):
79 else: 97 else:
80 connection_name = f"{connection_name} (via panel {panel.name})" 98 connection_name = f"{connection_name} (via panel {panel.name})"
81 99
82 if from_region in regions and to_region in regions: 100 if connection.HasField("purple_ending") and connection.purple_ending and world.options.strict_purple_ending:
83 connection = Entrance(world.player, connection_name, regions[from_region]) 101 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyz")
84 connection.access_rule = make_location_lambda(reqs, world) 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)
85 111
86 regions[from_region].exits.append(connection) 112 regions[from_region].exits.append(connection)
87 connection.connect(regions[to_region]) 113 connection.connect(regions[to_region])
88 114
89 for region in reqs.rooms: 115 for region in reqs.get_referenced_rooms():
90 world.multiworld.register_indirect_condition(regions[region], connection) 116 world.multiworld.register_indirect_condition(regions[region], connection)
91 117
92 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 index 4a84acf..c077858 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,32 +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 22 if not all(state.can_reach(region) for region in regions):
23 return False
19 24
20 for letter_key, letter_level in reqs.letters.items(): 25 for letter_key, letter_level in reqs.letters.items():
21 if not state.has(letter_key, world.player, letter_level): 26 if not state.has(letter_key, world.player, letter_level):
22 return False 27 return False
23 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
24 if len(reqs.or_logic) > 0: 34 if len(reqs.or_logic) > 0:
25 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, world) for sub_reqs in subjunction) 35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
26 for subjunction in reqs.or_logic): 36 for subjunction in reqs.or_logic):
27 return False 37 return False
28 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
29 return True 54 return True
30 55
31def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World") -> Callable[[CollectionState], bool]: 56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
32 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 965ce3e..e4d7d49 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, ANTI_COLLECTABLE_TRAPS
2import pkgutil 3import pkgutil
3 4
4class Lingo2StaticLogic: 5class Lingo2StaticLogic:
@@ -8,9 +9,17 @@ 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
15 letter_weights: dict[str, int]
16
11 def __init__(self): 17 def __init__(self):
12 self.item_id_to_name = {} 18 self.item_id_to_name = {}
13 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 = {}
14 23
15 file = pkgutil.get_data(__name__, "generated/data.binpb") 24 file = pkgutil.get_data(__name__, "generated/data.binpb")
16 self.objects = data_pb2.AllObjects() 25 self.objects = data_pb2.AllObjects()
@@ -18,38 +27,125 @@ class Lingo2StaticLogic:
18 27
19 for door in self.objects.doors: 28 for door in self.objects.doors:
20 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 29 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
21 location_name = f"{self.get_map_object_map_name(door)} - {door.name}" 30 location_name = self.get_door_location_name(door)
22 self.location_id_to_name[door.ap_id] = location_name 31 self.location_id_to_name[door.ap_id] = location_name
23 32
24 if door.type not in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 33 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) 34 item_name = self.get_door_item_name(door)
26 self.item_id_to_name[door.ap_id] = item_name 35 self.item_id_to_name[door.ap_id] = item_name
27 36
28 for letter in self.objects.letters: 37 for letter in self.objects.letters:
29 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 38 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
30 location_name = f"{self.get_room_object_map_name(letter)} - {letter_name}" 39 location_name = f"{self.get_room_object_map_name(letter)} - {letter_name}"
31 self.location_id_to_name[letter.ap_id] = location_name 40 self.location_id_to_name[letter.ap_id] = location_name
41 self.location_name_groups.setdefault("Letters", []).append(location_name)
32 42
33 if not letter.level2: 43 if not letter.level2:
34 self.item_id_to_name[letter.ap_id] = letter_name 44 self.item_id_to_name[letter.ap_id] = letter.key.upper()
45 self.item_name_groups.setdefault("Letters", []).append(letter.key.upper())
35 46
36 for mastery in self.objects.masteries: 47 for mastery in self.objects.masteries:
37 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery" 48 location_name = f"{self.get_room_object_map_name(mastery)} - Mastery"
38 self.location_id_to_name[mastery.ap_id] = location_name 49 self.location_id_to_name[mastery.ap_id] = location_name
50 self.location_name_groups.setdefault("Masteries", []).append(location_name)
39 51
40 for ending in self.objects.endings: 52 for ending in self.objects.endings:
41 location_name = f"{self.get_room_object_map_name(ending)} - {ending.name.title()} Ending" 53 location_name = f"{self.get_room_object_map_name(ending)} - {ending.name.title()} Ending"
42 self.location_id_to_name[ending.ap_id] = location_name 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
43 62
44 self.item_id_to_name[self.objects.special_ids["Nothing"]] = "Nothing" 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
45 76
46 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()}
47 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()}
48 79
49 def get_door_item_name(self, door_id: int) -> str: 80 for panel in self.objects.panels:
50 door = self.objects.doors[door_id] 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:
51 return f"{self.get_map_object_map_name(door)} - {door.name}" 85 return f"{self.get_map_object_map_name(door)} - {door.name}"
52 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
53 def get_room_region_name(self, room_id: int) -> str: 149 def get_room_region_name(self, room_id: int) -> str:
54 room = self.objects.rooms[room_id] 150 room = self.objects.rooms[room_id]
55 return f"{self.get_map_object_map_name(room)} - {room.name}" 151 return f"{self.get_map_object_map_name(room)} - {room.name}"
@@ -59,3 +155,15 @@ class Lingo2StaticLogic:
59 155
60 def get_room_object_map_name(self, obj) -> str: 156 def get_room_object_map_name(self, obj) -> str:
61 return self.get_map_object_map_name(self.objects.rooms[obj.room_id]) 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