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.md13
-rw-r--r--apworld/README.md48
-rw-r--r--apworld/__init__.py49
-rw-r--r--apworld/docs/en_Lingo_2.md4
-rw-r--r--apworld/items.py26
-rw-r--r--apworld/options.py122
-rw-r--r--apworld/player_logic.py344
-rw-r--r--apworld/regions.py31
-rw-r--r--apworld/requirements.txt2
-rw-r--r--apworld/rules.py43
-rw-r--r--apworld/static_logic.py63
-rw-r--r--apworld/version.py1
12 files changed, 663 insertions, 83 deletions
diff --git a/apworld/CHANGELOG.md b/apworld/CHANGELOG.md new file mode 100644 index 0000000..7db040c --- /dev/null +++ b/apworld/CHANGELOG.md
@@ -0,0 +1,13 @@
1# lingo2-archipelago Apworld Releases
2
3## v3.2 - 2025-09-12
4
5- Initial release for testing. Features include door shuffle, letter shuffle,
6 and symbol shuffle.
7
8Download:
9[lingo2.apworld](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/lingo2.apworld)<br/>
10Template YAML:
11[Lingo 2.yaml](https://files.fourisland.com/releases/lingo2-archipelago/apworld/v3.2/Lingo%202.yaml)<br/>
12Source:
13[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 4e5777a..8b2e42e 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,13 +62,29 @@ 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):
@@ -65,11 +92,23 @@ class Lingo2World(World):
65 92
66 def fill_slot_data(self): 93 def fill_slot_data(self):
67 slot_options = [ 94 slot_options = [
68 "victory_condition", "shuffle_doors", 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 "victory_condition",
69 ] 104 ]
70 105
71 slot_data = { 106 slot_data = {
72 **self.options.as_dict(*slot_options), 107 **self.options.as_dict(*slot_options),
108 "version": [self.static_logic.get_data_version(), APWORLD_VERSION],
73 } 109 }
74 110
75 return slot_data 111 return slot_data
112
113 def get_filler_item_name(self) -> str:
114 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..52d2034 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,15 +1,115 @@
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
11class VictoryCondition(Choice): 95class VictoryCondition(Choice):
12 """Victory condition.""" 96 """
97 This option determines what your goal is.
98
99 - **Gray Ending** (The Colorful)
100 - **Purple Ending** (The Sun Temple). This ordinarily requires all level 1 (purple) letters.
101 - **Mint Ending** (typing EXIT into the keyholders in Control Center)
102 - **Black Ending** (The Graveyard)
103 - **Blue Ending** (The Words)
104 - **Cyan Ending** (The Parthenon). This ordinarily requires almost all level 2 (cyan) letters.
105 - **Red Ending** (The Tower)
106 - **Plum Ending** (The Wondrous / The Door)
107 - **Orange Ending** (the castle in Daedalus)
108 - **Gold Ending** (The Gold). This involves going through the color rooms in Daedalus.
109 - **Yellow Ending** (The Gallery). This requires unlocking all gallery paintings.
110 - **Green Ending** (The Ancient). This requires filling all keyholders with specific letters.
111 - **White Ending** (Control Center). This combines every other ending.
112 """
13 display_name = "Victory Condition" 113 display_name = "Victory Condition"
14 option_gray_ending = 0 114 option_gray_ending = 0
15 option_purple_ending = 1 115 option_purple_ending = 1
@@ -26,7 +126,23 @@ class VictoryCondition(Choice):
26 option_white_ending = 12 126 option_white_ending = 12
27 127
28 128
129class TrapPercentage(Range):
130 """Replaces junk items with traps, at the specified rate."""
131 display_name = "Trap Percentage"
132 range_start = 0
133 range_end = 100
134 default = 0
135
136
29@dataclass 137@dataclass
30class Lingo2Options(PerGameCommonOptions): 138class Lingo2Options(PerGameCommonOptions):
31 shuffle_doors: ShuffleDoors 139 shuffle_doors: ShuffleDoors
140 shuffle_control_center_colors: ShuffleControlCenterColors
141 shuffle_gallery_paintings: ShuffleGalleryPaintings
142 shuffle_letters: ShuffleLetters
143 shuffle_symbols: ShuffleSymbols
144 keyholder_sanity: KeyholderSanity
145 cyan_door_behavior: CyanDoorBehavior
146 daedalus_roof_access: DaedalusRoofAccess
32 victory_condition: VictoryCondition 147 victory_condition: VictoryCondition
148 trap_percentage: TrapPercentage
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index c6465f6..17af77f 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -1,7 +1,10 @@
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
4from .options import VictoryCondition 7from .options import VictoryCondition, ShuffleLetters, CyanDoorBehavior
5 8
6if TYPE_CHECKING: 9if TYPE_CHECKING:
7 from . import Lingo2World 10 from . import Lingo2World
@@ -14,64 +17,147 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
14 real_l = l.upper() 17 real_l = l.upper()
15 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2) 18 histogram[real_l] = min(histogram.get(real_l, 0) + 1, 2)
16 19
17 for free_letter in "HINT":
18 if histogram.get(free_letter, 0) == 1:
19 del histogram[free_letter]
20
21 return histogram 20 return histogram
22 21
23 22
24class AccessRequirements: 23class AccessRequirements:
25 items: set[str] 24 items: set[str]
25 progressives: dict[str, int]
26 rooms: set[str] 26 rooms: set[str]
27 symbols: set[str]
28 letters: dict[str, int] 27 letters: dict[str, int]
28 cyans: bool
29 29
30 # This is an AND of ORs. 30 # This is an AND of ORs.
31 or_logic: list[list["AccessRequirements"]] 31 or_logic: list[list["AccessRequirements"]]
32 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
33 def __init__(self): 38 def __init__(self):
34 self.items = set() 39 self.items = set()
40 self.progressives = dict()
35 self.rooms = set() 41 self.rooms = set()
36 self.symbols = set()
37 self.letters = dict() 42 self.letters = dict()
43 self.cyans = False
38 self.or_logic = list() 44 self.or_logic = list()
45 self.complete_at = None
46 self.possibilities = list()
39 47
40 def add_solution(self, solution: str): 48 def copy(self) -> "AccessRequirements":
41 histogram = calculate_letter_histogram(solution) 49 reqs = AccessRequirements()
42 50 reqs.items = self.items.copy()
43 for l, a in histogram.items(): 51 reqs.progressives = self.progressives.copy()
44 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
45 59
46 def merge(self, other: "AccessRequirements"): 60 def merge(self, other: "AccessRequirements"):
47 for item in other.items: 61 for item in other.items:
48 self.items.add(item) 62 self.items.add(item)
49 63
64 for item, amount in other.progressives.items():
65 self.progressives[item] = max(amount, self.progressives.get(item, 0))
66
50 for room in other.rooms: 67 for room in other.rooms:
51 self.rooms.add(room) 68 self.rooms.add(room)
52 69
53 for symbol in other.symbols:
54 self.symbols.add(symbol)
55
56 for letter, level in other.letters.items(): 70 for letter, level in other.letters.items():
57 self.letters[letter] = max(self.letters.get(letter, 0), level) 71 self.letters[letter] = max(self.letters.get(letter, 0), level)
58 72
73 self.cyans = self.cyans or other.cyans
74
59 for disjunction in other.or_logic: 75 for disjunction in other.or_logic:
60 self.or_logic.append(disjunction) 76 self.or_logic.append(disjunction)
61 77
78 if other.complete_at is not None:
79 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
80 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
81 # conjunctions of requirements.
82 if self.complete_at is not None:
83 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
84
85 left_req = AccessRequirements()
86 left_req.complete_at = self.complete_at
87 left_req.possibilities = self.possibilities
88 self.or_logic.append([left_req])
89
90 self.complete_at = None
91 self.possibilities = list()
92
93 right_req = AccessRequirements()
94 right_req.complete_at = other.complete_at
95 right_req.possibilities = other.possibilities
96 self.or_logic.append([right_req])
97 else:
98 self.complete_at = other.complete_at
99 self.possibilities = other.possibilities
100
101 def is_empty(self) -> bool:
102 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
103 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
104
105 def __eq__(self, other: "AccessRequirements"):
106 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
107 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
108 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
109
110 def simplify(self):
111 resimplify = False
112
113 if len(self.or_logic) > 0:
114 old_or_logic = self.or_logic
115
116 def remove_redundant(sub_reqs: "AccessRequirements"):
117 sub_reqs.letters = {l: v for l, v in sub_reqs.letters.items() if self.letters.get(l, 0) < v}
118
119 self.or_logic = []
120 for disjunction in old_or_logic:
121 new_disjunction = []
122 for ssr in disjunction:
123 remove_redundant(ssr)
124 if not ssr.is_empty():
125 new_disjunction.append(ssr)
126 else:
127 new_disjunction.clear()
128 break
129 if len(new_disjunction) == 1:
130 self.merge(new_disjunction[0])
131 resimplify = True
132 elif len(new_disjunction) > 1:
133 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
134 self.merge(new_disjunction[0])
135 resimplify = True
136 else:
137 self.or_logic.append(new_disjunction)
138
139 if resimplify:
140 self.simplify()
141
62 def __repr__(self): 142 def __repr__(self):
63 parts = [] 143 parts = []
64 if len(self.items) > 0: 144 if len(self.items) > 0:
65 parts.append(f"items={self.items}") 145 parts.append(f"items={self.items}")
146 if len(self.progressives) > 0:
147 parts.append(f"progressives={self.progressives}")
66 if len(self.rooms) > 0: 148 if len(self.rooms) > 0:
67 parts.append(f"rooms={self.rooms}") 149 parts.append(f"rooms={self.rooms}")
68 if len(self.symbols) > 0:
69 parts.append(f"symbols={self.symbols}")
70 if len(self.letters) > 0: 150 if len(self.letters) > 0:
71 parts.append(f"letters={self.letters}") 151 parts.append(f"letters={self.letters}")
152 if self.cyans:
153 parts.append(f"cyans=True")
72 if len(self.or_logic) > 0: 154 if len(self.or_logic) > 0:
73 parts.append(f"or_logic={self.or_logic}") 155 parts.append(f"or_logic={self.or_logic}")
74 return f"AccessRequirements({", ".join(parts)})" 156 if self.complete_at is not None:
157 parts.append(f"complete_at={self.complete_at}")
158 if len(self.possibilities) > 0:
159 parts.append(f"possibilities={self.possibilities}")
160 return "AccessRequirements(" + ", ".join(parts) + ")"
75 161
76 162
77class PlayerLocation(NamedTuple): 163class PlayerLocation(NamedTuple):
@@ -79,13 +165,19 @@ class PlayerLocation(NamedTuple):
79 reqs: AccessRequirements 165 reqs: AccessRequirements
80 166
81 167
168class LetterBehavior(IntEnum):
169 VANILLA = auto()
170 ITEM = auto()
171 UNLOCKED = auto()
172
173
82class Lingo2PlayerLogic: 174class Lingo2PlayerLogic:
83 world: "Lingo2World" 175 world: "Lingo2World"
84 176
85 locations_by_room: dict[int, list[PlayerLocation]] 177 locations_by_room: dict[int, list[PlayerLocation]]
86 event_loc_item_by_room: dict[int, dict[str, str]] 178 event_loc_item_by_room: dict[int, dict[str, str]]
87 179
88 item_by_door: dict[int, str] 180 item_by_door: dict[int, tuple[str, int]]
89 181
90 panel_reqs: dict[int, AccessRequirements] 182 panel_reqs: dict[int, AccessRequirements]
91 proxy_reqs: dict[int, dict[str, AccessRequirements]] 183 proxy_reqs: dict[int, dict[str, AccessRequirements]]
@@ -93,6 +185,8 @@ class Lingo2PlayerLogic:
93 185
94 real_items: list[str] 186 real_items: list[str]
95 187
188 double_letter_amount: dict[str, int]
189
96 def __init__(self, world: "Lingo2World"): 190 def __init__(self, world: "Lingo2World"):
97 self.world = world 191 self.world = world
98 self.locations_by_room = {} 192 self.locations_by_room = {}
@@ -102,14 +196,68 @@ class Lingo2PlayerLogic:
102 self.proxy_reqs = dict() 196 self.proxy_reqs = dict()
103 self.door_reqs = dict() 197 self.door_reqs = dict()
104 self.real_items = list() 198 self.real_items = list()
199 self.double_letter_amount = dict()
200
201 if self.world.options.shuffle_doors:
202 for progressive in world.static_logic.objects.progressives:
203 for i in range(0, len(progressive.doors)):
204 self.item_by_door[progressive.doors[i]] = (progressive.name, i + 1)
205 self.real_items.append(progressive.name)
206
207 for door_group in world.static_logic.objects.door_groups:
208 if door_group.type == data_pb2.DoorGroupType.CONNECTOR:
209 if not self.world.options.shuffle_doors:
210 continue
211 elif door_group.type == data_pb2.DoorGroupType.COLOR_CONNECTOR:
212 if not self.world.options.shuffle_control_center_colors:
213 continue
214 elif door_group.type == data_pb2.DoorGroupType.SHUFFLE_GROUP:
215 if not self.world.options.shuffle_doors:
216 continue
217 else:
218 continue
219
220 for door in door_group.doors:
221 self.item_by_door[door] = (door_group.name, 1)
222
223 self.real_items.append(door_group.name)
105 224
106 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled 225 # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
107 # before we calculate any access requirements. 226 # before we calculate any access requirements.
108 for door in world.static_logic.objects.doors: 227 for door in world.static_logic.objects.doors:
109 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors: 228 if door.type in [data_pb2.DoorType.EVENT, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
110 door_item_name = self.world.static_logic.get_door_item_name(door) 229 continue
111 self.item_by_door[door.id] = door_item_name 230
112 self.real_items.append(door_item_name) 231 if door.id in self.item_by_door:
232 continue
233
234 if (door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and
235 not self.world.options.shuffle_doors):
236 continue
237
238 if (door.type == data_pb2.DoorType.CONTROL_CENTER_COLOR and
239 not self.world.options.shuffle_control_center_colors):
240 continue
241
242 if door.type == data_pb2.DoorType.GALLERY_PAINTING and not self.world.options.shuffle_gallery_paintings:
243 continue
244
245 door_item_name = self.world.static_logic.get_door_item_name(door)
246 self.item_by_door[door.id] = (door_item_name, 1)
247 self.real_items.append(door_item_name)
248
249 # We handle cyan_door_behavior = Item after door shuffle, because cyan doors that are impacted by door shuffle
250 # should be exempt from cyan_door_behavior.
251 if world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
252 for door_group in world.static_logic.objects.door_groups:
253 if door_group.type != data_pb2.DoorGroupType.CYAN_DOORS:
254 continue
255
256 for door in door_group.doors:
257 if not door in self.item_by_door:
258 self.item_by_door[door] = (door_group.name, 1)
259
260 self.real_items.append(door_group.name)
113 261
114 for door in world.static_logic.objects.doors: 262 for door in world.static_logic.objects.doors:
115 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]: 263 if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
@@ -119,14 +267,20 @@ class Lingo2PlayerLogic:
119 for letter in world.static_logic.objects.letters: 267 for letter in world.static_logic.objects.letters:
120 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id, 268 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
121 AccessRequirements())) 269 AccessRequirements()))
270 behavior = self.get_letter_behavior(letter.key, letter.level2)
271 if behavior == LetterBehavior.VANILLA:
272 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
273 event_name = f"{letter_name} (Collected)"
274 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
122 275
123 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 276 if letter.level2:
124 event_name = f"{letter_name} (Collected)" 277 event_name = f"{letter_name} (Double Collected)"
125 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 278 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
279 elif behavior == LetterBehavior.ITEM:
280 self.real_items.append(letter.key.upper())
126 281
127 if letter.level2: 282 if behavior != LetterBehavior.UNLOCKED:
128 event_name = f"{letter_name} (Double Collected)" 283 self.double_letter_amount[letter.key.upper()] = self.double_letter_amount.get(letter.key.upper(), 0) + 1
129 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
130 284
131 for mastery in world.static_logic.objects.masteries: 285 for mastery in world.static_logic.objects.masteries:
132 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id, 286 self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
@@ -150,6 +304,21 @@ class Lingo2PlayerLogic:
150 304
151 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name 305 self.event_loc_item_by_room.setdefault(ending.room_id, {})[event_name] = item_name
152 306
307 if self.world.options.keyholder_sanity:
308 for keyholder in world.static_logic.objects.keyholders:
309 if keyholder.HasField("key"):
310 reqs = AccessRequirements()
311
312 if self.get_letter_behavior(keyholder.key, False) != LetterBehavior.UNLOCKED:
313 reqs.letters[keyholder.key.upper()] = 1
314
315 self.locations_by_room.setdefault(keyholder.room_id, []).append(PlayerLocation(keyholder.ap_id,
316 reqs))
317
318 if self.world.options.shuffle_symbols:
319 for symbol_name in SYMBOL_ITEMS.values():
320 self.real_items.append(symbol_name)
321
153 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements: 322 def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
154 if answer is None: 323 if answer is None:
155 if panel_id not in self.panel_reqs: 324 if panel_id not in self.panel_reqs:
@@ -169,28 +338,38 @@ class Lingo2PlayerLogic:
169 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id)) 338 reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))
170 339
171 if answer is not None: 340 if answer is not None:
172 reqs.add_solution(answer) 341 self.add_solution_reqs(reqs, answer)
173 elif len(panel.proxies) > 0: 342 elif len(panel.proxies) > 0:
174 possibilities = [] 343 possibilities = []
344 already_filled = False
175 345
176 for proxy in panel.proxies: 346 for proxy in panel.proxies:
177 proxy_reqs = AccessRequirements() 347 proxy_reqs = AccessRequirements()
178 proxy_reqs.add_solution(proxy.answer) 348 self.add_solution_reqs(proxy_reqs, proxy.answer)
179 349
180 possibilities.append(proxy_reqs) 350 if not proxy_reqs.is_empty():
351 possibilities.append(proxy_reqs)
352 else:
353 already_filled = True
354 break
181 355
182 if not any(proxy.answer == panel.answer for proxy in panel.proxies): 356 if not already_filled and not any(proxy.answer == panel.answer for proxy in panel.proxies):
183 proxy_reqs = AccessRequirements() 357 proxy_reqs = AccessRequirements()
184 proxy_reqs.add_solution(panel.answer) 358 self.add_solution_reqs(proxy_reqs, panel.answer)
185 359
186 possibilities.append(proxy_reqs) 360 if not proxy_reqs.is_empty():
361 possibilities.append(proxy_reqs)
362 else:
363 already_filled = True
187 364
188 reqs.or_logic.append(possibilities) 365 if not already_filled:
366 reqs.or_logic.append(possibilities)
189 else: 367 else:
190 reqs.add_solution(panel.answer) 368 self.add_solution_reqs(reqs, panel.answer)
191 369
192 for symbol in panel.symbols: 370 if self.world.options.shuffle_symbols:
193 reqs.symbols.add(symbol) 371 for symbol in panel.symbols:
372 reqs.items.add(SYMBOL_ITEMS.get(symbol))
194 373
195 if panel.HasField("required_door"): 374 if panel.HasField("required_door"):
196 door_reqs = self.get_door_open_reqs(panel.required_door) 375 door_reqs = self.get_door_open_reqs(panel.required_door)
@@ -213,31 +392,46 @@ class Lingo2PlayerLogic:
213 door = self.world.static_logic.objects.doors[door_id] 392 door = self.world.static_logic.objects.doors[door_id]
214 reqs = AccessRequirements() 393 reqs = AccessRequirements()
215 394
216 # TODO: lavender_cubes, endings
217 if not door.HasField("complete_at") or door.complete_at == 0: 395 if not door.HasField("complete_at") or door.complete_at == 0:
218 for proxy in door.panels: 396 for proxy in door.panels:
219 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None) 397 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
220 reqs.merge(panel_reqs) 398 reqs.merge(panel_reqs)
221 elif door.complete_at == 1: 399 elif door.complete_at == 1:
222 reqs.or_logic.append([self.get_panel_reqs(proxy.panel, 400 disjunction = []
223 proxy.answer if proxy.HasField("answer") else None) 401 for proxy in door.panels:
224 for proxy in door.panels]) 402 proxy_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
403 if proxy_reqs.is_empty():
404 disjunction.clear()
405 break
406 else:
407 disjunction.append(proxy_reqs)
408 if len(disjunction) > 0:
409 reqs.or_logic.append(disjunction)
225 else: 410 else:
226 # TODO: Handle complete_at > 1 411 reqs.complete_at = door.complete_at
227 pass 412 for proxy in door.panels:
413 panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
414 reqs.possibilities.append(panel_reqs)
228 415
229 if door.HasField("control_center_color"): 416 if door.HasField("control_center_color"):
230 # TODO: Logic for ensuring two CC states aren't needed at once. 417 # TODO: Logic for ensuring two CC states aren't needed at once.
231 reqs.rooms.add("Control Center - Main Area") 418 reqs.rooms.add("Control Center - Main Area")
232 reqs.add_solution(door.control_center_color) 419 self.add_solution_reqs(reqs, door.control_center_color)
233 420
234 if door.double_letters: 421 if door.double_letters:
235 # TODO: When letter shuffle is on, change this to require any double letter instead. 422 if self.world.options.cyan_door_behavior == CyanDoorBehavior.option_collect_h2:
236 reqs.rooms.add("The Repetitive - Main Room") 423 reqs.rooms.add("The Repetitive - Main Room")
424 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_any_double_letter:
425 if self.world.options.shuffle_letters != ShuffleLetters.option_unlocked:
426 reqs.cyans = True
427 elif self.world.options.cyan_door_behavior == CyanDoorBehavior.option_item:
428 # There shouldn't be any locations that are cyan doors.
429 pass
237 430
238 for keyholder_uses in door.keyholders: 431 for keyholder_uses in door.keyholders:
239 key_name = keyholder_uses.key.upper() 432 key_name = keyholder_uses.key.upper()
240 if key_name not in reqs.letters: 433 if (self.get_letter_behavior(keyholder_uses.key, False) != LetterBehavior.UNLOCKED
434 and key_name not in reqs.letters):
241 reqs.letters[key_name] = 1 435 reqs.letters[key_name] = 1
242 436
243 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder] 437 keyholder = self.world.static_logic.objects.keyholders[keyholder_uses.keyholder]
@@ -248,12 +442,18 @@ class Lingo2PlayerLogic:
248 442
249 for ending_id in door.endings: 443 for ending_id in door.endings:
250 ending = self.world.static_logic.objects.endings[ending_id] 444 ending = self.world.static_logic.objects.endings[ending_id]
251 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)") 445
446 if self.world.options.victory_condition.current_key.removesuffix("_ending").upper() == ending.name:
447 reqs.items.add("Victory")
448 else:
449 reqs.items.add(f"{ending.name.capitalize()} Ending (Achieved)")
252 450
253 for sub_door_id in door.doors: 451 for sub_door_id in door.doors:
254 sub_reqs = self.get_door_open_reqs(sub_door_id) 452 sub_reqs = self.get_door_open_reqs(sub_door_id)
255 reqs.merge(sub_reqs) 453 reqs.merge(sub_reqs)
256 454
455 reqs.simplify()
456
257 return reqs 457 return reqs
258 458
259 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item 459 # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
@@ -261,8 +461,50 @@ class Lingo2PlayerLogic:
261 def get_door_open_reqs(self, door_id: int) -> AccessRequirements: 461 def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
262 if door_id in self.item_by_door: 462 if door_id in self.item_by_door:
263 reqs = AccessRequirements() 463 reqs = AccessRequirements()
264 reqs.items.add(self.item_by_door.get(door_id)) 464
465 item_name, amount = self.item_by_door.get(door_id)
466 if amount == 1:
467 reqs.items.add(item_name)
468 else:
469 reqs.progressives[item_name] = amount
265 470
266 return reqs 471 return reqs
267 else: 472 else:
268 return self.get_door_reqs(door_id) 473 return self.get_door_reqs(door_id)
474
475 def get_letter_behavior(self, letter: str, level2: bool) -> LetterBehavior:
476 if self.world.options.shuffle_letters == ShuffleLetters.option_unlocked:
477 return LetterBehavior.UNLOCKED
478
479 if self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla_cyan, ShuffleLetters.option_item_cyan]:
480 if level2:
481 if self.world.options.shuffle_letters == ShuffleLetters.option_vanilla_cyan:
482 return LetterBehavior.VANILLA
483 else:
484 return LetterBehavior.ITEM
485 else:
486 return LetterBehavior.UNLOCKED
487
488 if not level2 and letter in ["h", "i", "n", "t"]:
489 return LetterBehavior.UNLOCKED
490
491 if self.world.options.shuffle_letters == ShuffleLetters.option_progressive:
492 return LetterBehavior.ITEM
493
494 return LetterBehavior.VANILLA
495
496 def add_solution_reqs(self, reqs: AccessRequirements, solution: str):
497 histogram = calculate_letter_histogram(solution)
498
499 for l, a in histogram.items():
500 needed = min(a, 2)
501 level2 = (needed == 2)
502
503 if level2 and self.get_letter_behavior(l.lower(), True) == LetterBehavior.UNLOCKED:
504 needed = 1
505
506 if self.get_letter_behavior(l.lower(), False) == LetterBehavior.UNLOCKED:
507 needed = needed - 1
508
509 if needed > 0:
510 reqs.letters[l] = max(reqs.letters.get(l, 0), needed)
diff --git a/apworld/regions.py b/apworld/regions.py index fe2c99b..4f1dd55 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -11,12 +11,18 @@ 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 if new_region.name in reqs.rooms:
21 reqs.rooms.remove(new_region.name)
22
17 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 23 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code],
18 location.code, new_region) 24 location.code, new_region)
19 new_location.access_rule = make_location_lambda(location.reqs, world) 25 new_location.access_rule = make_location_lambda(reqs, world, regions)
20 new_region.locations.append(new_location) 26 new_region.locations.append(new_location)
21 27
22 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): 28 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
@@ -25,22 +31,31 @@ def create_region(room, world: "Lingo2World") -> Region:
25 new_location.place_locked_item(event_item) 31 new_location.place_locked_item(event_item)
26 new_region.locations.append(new_location) 32 new_region.locations.append(new_location)
27 33
28 return new_region
29
30
31def create_regions(world: "Lingo2World"): 34def create_regions(world: "Lingo2World"):
32 regions = { 35 regions = {
33 "Menu": Region("Menu", world.player, world.multiworld) 36 "Menu": Region("Menu", world.player, world.multiworld)
34 } 37 }
35 38
39 region_and_room = []
40
41 # Create the regions in two stages. First, make the actual region objects and memoize them. Then, add all of the
42 # locations. This allows us to reference the actual region objects in the access rules for the locations, which is
43 # faster than having to look them up during access checking.
36 for room in world.static_logic.objects.rooms: 44 for room in world.static_logic.objects.rooms:
37 region = create_region(room, world) 45 region = create_region(room, world)
38 regions[region.name] = region 46 regions[region.name] = region
47 region_and_room.append((region, room))
48
49 for (region, room) in region_and_room:
50 create_locations(room, region, world, regions)
39 51
40 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 52 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
41 53
42 # TODO: The requirements of the opposite trigger also matter. 54 # TODO: The requirements of the opposite trigger also matter.
43 for connection in world.static_logic.objects.connections: 55 for connection in world.static_logic.objects.connections:
56 if connection.roof_access and not world.options.daedalus_roof_access:
57 continue
58
44 from_region = world.static_logic.get_room_region_name(connection.from_room) 59 from_region = world.static_logic.get_room_region_name(connection.from_room)
45 to_region = world.static_logic.get_room_region_name(connection.to_room) 60 to_region = world.static_logic.get_room_region_name(connection.to_room)
46 connection_name = f"{from_region} -> {to_region}" 61 connection_name = f"{from_region} -> {to_region}"
@@ -79,14 +94,18 @@ def create_regions(world: "Lingo2World"):
79 else: 94 else:
80 connection_name = f"{connection_name} (via panel {panel.name})" 95 connection_name = f"{connection_name} (via panel {panel.name})"
81 96
97 reqs.simplify()
98
82 if from_region in regions and to_region in regions: 99 if from_region in regions and to_region in regions:
83 connection = Entrance(world.player, connection_name, regions[from_region]) 100 connection = Entrance(world.player, connection_name, regions[from_region])
84 connection.access_rule = make_location_lambda(reqs, world) 101 connection.access_rule = make_location_lambda(reqs, world, regions)
85 102
86 regions[from_region].exits.append(connection) 103 regions[from_region].exits.append(connection)
87 connection.connect(regions[to_region]) 104 connection.connect(regions[to_region])
88 105
89 for region in reqs.rooms: 106 for region in reqs.rooms:
107 if region == from_region:
108 continue
90 world.multiworld.register_indirect_condition(regions[region], connection) 109 world.multiworld.register_indirect_condition(regions[region], connection)
91 110
92 world.multiworld.regions += regions.values() 111 world.multiworld.regions += regions.values()
diff --git a/apworld/requirements.txt b/apworld/requirements.txt index b701d11..dbc395b 100644 --- a/apworld/requirements.txt +++ b/apworld/requirements.txt
@@ -1 +1 @@
protobuf>=5.29.3 \ No newline at end of file protobuf==3.20.3
diff --git a/apworld/rules.py b/apworld/rules.py index 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 ff1f17d..2700601 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()
@@ -29,23 +38,49 @@ class Lingo2StaticLogic:
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
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)
43 68
44 self.item_id_to_name[self.objects.special_ids["Nothing"]] = "Nothing" 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
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
49 def get_door_item_name(self, door: data_pb2.Door) -> str: 84 def get_door_item_name(self, door: data_pb2.Door) -> str:
50 return f"{self.get_map_object_map_name(door)} - {door.name}" 85 return f"{self.get_map_object_map_name(door)} - {door.name}"
51 86
@@ -54,13 +89,7 @@ class Lingo2StaticLogic:
54 return self.get_door_item_name(door_id) 89 return self.get_door_item_name(door_id)
55 90
56 def get_door_location_name(self, door: data_pb2.Door) -> str: 91 def get_door_location_name(self, door: data_pb2.Door) -> str:
57 game_map = self.objects.maps[door.map_id] 92 map_part = self.get_room_object_location_prefix(door)
58 room = self.objects.rooms[door.room_id]
59
60 if room.HasField("panel_display_name"):
61 map_part = f"{game_map.display_name} ({room.panel_display_name})"
62 else:
63 map_part = game_map.display_name
64 93
65 if door.HasField("location_name"): 94 if door.HasField("location_name"):
66 return f"{map_part} - {door.location_name}" 95 return f"{map_part} - {door.location_name}"
@@ -75,7 +104,7 @@ class Lingo2StaticLogic:
75 if door.type != data_pb2.DoorType.STANDARD: 104 if door.type != data_pb2.DoorType.STANDARD:
76 return None 105 return None
77 106
78 if len(door.keyholders) > 0 or len(door.endings) > 0: 107 if len(door.keyholders) > 0 or len(door.endings) > 0 or not door.HasField("complete_at"):
79 return None 108 return None
80 109
81 if len(door.panels) > 4: 110 if len(door.panels) > 4:
@@ -111,7 +140,7 @@ class Lingo2StaticLogic:
111 for panel_id in door.panels] 140 for panel_id in door.panels]
112 panel_names.sort() 141 panel_names.sort()
113 142
114 return f"{map_part} - {", ".join(panel_names)}" 143 return map_part + " - " + ", ".join(panel_names)
115 144
116 def get_door_location_name_by_id(self, door_id: int) -> str: 145 def get_door_location_name_by_id(self, door_id: int) -> str:
117 door = self.objects.doors[door_id] 146 door = self.objects.doors[door_id]
@@ -126,3 +155,15 @@ class Lingo2StaticLogic:
126 155
127 def get_room_object_map_name(self, obj) -> str: 156 def get_room_object_map_name(self, obj) -> str:
128 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..1b62c0a --- /dev/null +++ b/apworld/version.py
@@ -0,0 +1 @@
APWORLD_VERSION = 3