about summary refs log tree commit diff stats
path: root/apworld
diff options
context:
space:
mode:
Diffstat (limited to 'apworld')
-rw-r--r--apworld/__init__.py19
-rw-r--r--apworld/client/gamedata.gd8
-rw-r--r--apworld/client/manager.gd31
-rw-r--r--apworld/client/maps/control_center.gd55
-rw-r--r--apworld/client/player.gd40
-rw-r--r--apworld/client/rteMenu.gd55
-rw-r--r--apworld/items.py5
-rw-r--r--apworld/locations.py106
-rw-r--r--apworld/options.py55
-rw-r--r--apworld/player_logic.py206
-rw-r--r--apworld/regions.py61
-rw-r--r--apworld/rules.py243
-rw-r--r--apworld/static_logic.py14
-rw-r--r--apworld/tracker.py4
14 files changed, 661 insertions, 241 deletions
diff --git a/apworld/__init__.py b/apworld/__init__.py index 5bad63e..6b5338e 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -7,7 +7,7 @@ from BaseClasses import ItemClassification, Item, Tutorial
7from Options import OptionError 7from Options import OptionError
8from settings import Group, UserFilePath 8from settings import Group, UserFilePath
9from worlds.AutoWorld import WebWorld, World 9from worlds.AutoWorld import WebWorld, World
10from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS 10from .items import Lingo2Item, ANTI_COLLECTABLE_TRAPS, ALL_LETTERS_UPPER
11from .options import Lingo2Options 11from .options import Lingo2Options
12from .player_logic import Lingo2PlayerLogic 12from .player_logic import Lingo2PlayerLogic
13from .regions import create_regions, shuffle_entrances, connect_ports_from_ut 13from .regions import create_regions, shuffle_entrances, connect_ports_from_ut
@@ -70,7 +70,14 @@ class Lingo2World(World):
70 self.player_logic = Lingo2PlayerLogic(self) 70 self.player_logic = Lingo2PlayerLogic(self)
71 self.port_pairings = {} 71 self.port_pairings = {}
72 72
73 if self.options.restrict_letter_placements:
74 self.options.local_items.value |= set(ALL_LETTERS_UPPER)
75
73 def create_regions(self): 76 def create_regions(self):
77 if hasattr(self.multiworld, "re_gen_passthrough") and "Lingo 2" in self.multiworld.re_gen_passthrough:
78 self.player_logic.rte_mapping = [self.world.static_logic.map_id_by_name[map_name]
79 for map_name in self.multiworld.re_gen_passthrough["Lingo 2"]["rte"]]
80
74 create_regions(self) 81 create_regions(self)
75 82
76 def connect_entrances(self): 83 def connect_entrances(self):
@@ -124,11 +131,14 @@ class Lingo2World(World):
124 self.push_precollected(self.create_item(name)) 131 self.push_precollected(self.create_item(name))
125 132
126 def create_item(self, name: str) -> Item: 133 def create_item(self, name: str) -> Item:
127 return Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else 134 item = Lingo2Item(name, ItemClassification.filler if name == self.get_filler_item_name() else
128 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else 135 ItemClassification.trap if name in ANTI_COLLECTABLE_TRAPS else
129 ItemClassification.progression, 136 ItemClassification.progression,
130 self.item_name_to_id.get(name), self.player) 137 self.item_name_to_id.get(name), self.player)
131 138
139 item.is_letter = (name in ALL_LETTERS_UPPER)
140 return item
141
132 def set_rules(self): 142 def set_rules(self):
133 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) 143 self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
134 144
@@ -140,12 +150,14 @@ class Lingo2World(World):
140 "enable_gift_maps", 150 "enable_gift_maps",
141 "enable_icarus", 151 "enable_icarus",
142 "endings_requirement", 152 "endings_requirement",
153 "fast_travel_access",
143 "keyholder_sanity", 154 "keyholder_sanity",
144 "masteries_requirement", 155 "masteries_requirement",
145 "shuffle_control_center_colors", 156 "shuffle_control_center_colors",
146 "shuffle_doors", 157 "shuffle_doors",
147 "shuffle_gallery_paintings", 158 "shuffle_gallery_paintings",
148 "shuffle_letters", 159 "shuffle_letters",
160 "shuffle_music",
149 "shuffle_symbols", 161 "shuffle_symbols",
150 "shuffle_worldports", 162 "shuffle_worldports",
151 "strict_cyan_ending", 163 "strict_cyan_ending",
@@ -155,6 +167,9 @@ class Lingo2World(World):
155 167
156 slot_data: dict[str, object] = { 168 slot_data: dict[str, object] = {
157 **self.options.as_dict(*slot_options), 169 **self.options.as_dict(*slot_options),
170 "custom_mint_ending": self.player_logic.custom_mint_ending or "",
171 "rte": [self.static_logic.objects.maps[map_id].name for map_id in self.player_logic.rte_mapping],
172 "seed": self.random.randint(0, 1000000),
158 "version": self.static_logic.get_data_version(), 173 "version": self.static_logic.get_data_version(),
159 } 174 }
160 175
diff --git a/apworld/client/gamedata.gd b/apworld/client/gamedata.gd index d7e3136..373f981 100644 --- a/apworld/client/gamedata.gd +++ b/apworld/client/gamedata.gd
@@ -16,6 +16,7 @@ var anti_trap_ids = {}
16var location_name_by_id = {} 16var location_name_by_id = {}
17var ending_display_name_by_name = {} 17var ending_display_name_by_name = {}
18var port_id_by_ap_id = {} 18var port_id_by_ap_id = {}
19var map_id_by_rte_ap_id = {}
19 20
20var kSYMBOL_ITEMS 21var kSYMBOL_ITEMS
21 22
@@ -57,6 +58,9 @@ func load(data_bytes):
57 for map in objects.get_maps(): 58 for map in objects.get_maps():
58 map_id_by_name[map.get_name()] = map.get_id() 59 map_id_by_name[map.get_name()] = map.get_id()
59 60
61 if map.has_rte_ap_id():
62 map_id_by_rte_ap_id[map.get_rte_ap_id()] = map.get_id()
63
60 for door in objects.get_doors(): 64 for door in objects.get_doors():
61 var map = objects.get_maps()[door.get_map_id()] 65 var map = objects.get_maps()[door.get_map_id()]
62 66
@@ -300,3 +304,7 @@ func _get_keyholder_location_name(keyholder):
300 "%s - %s Keyholder" 304 "%s - %s Keyholder"
301 % [_get_room_object_location_prefix(keyholder), keyholder.get_key().to_upper()] 305 % [_get_room_object_location_prefix(keyholder), keyholder.get_key().to_upper()]
302 ) 306 )
307
308
309func vec3d_to_vector3(input) -> Vector3:
310 return Vector3(input.get_x(), input.get_y(), input.get_z())
diff --git a/apworld/client/manager.gd b/apworld/client/manager.gd index 1e0b549..f10a0b7 100644 --- a/apworld/client/manager.gd +++ b/apworld/client/manager.gd
@@ -46,6 +46,10 @@ const kCYAN_DOOR_BEHAVIOR_H2 = 0
46const kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER = 1 46const kCYAN_DOOR_BEHAVIOR_DOUBLE_LETTER = 1
47const kCYAN_DOOR_BEHAVIOR_ITEM = 2 47const kCYAN_DOOR_BEHAVIOR_ITEM = 2
48 48
49const kFAST_TRAVEL_ACCESS_VANILLA = 0
50const kFAST_TRAVEL_ACCESS_UNLOCKED = 1
51const kFAST_TRAVEL_ACCESS_ITEMS = 2
52
49const kEndingNameByVictoryValue = { 53const kEndingNameByVictoryValue = {
50 0: "GRAY", 54 0: "GRAY",
51 1: "PURPLE", 55 1: "PURPLE",
@@ -63,21 +67,26 @@ const kEndingNameByVictoryValue = {
63} 67}
64 68
65var apworld_version = [0, 0, 0] 69var apworld_version = [0, 0, 0]
70var custom_mint_ending = ""
66var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2 71var cyan_door_behavior = kCYAN_DOOR_BEHAVIOR_H2
67var daedalus_only = false 72var daedalus_only = false
68var daedalus_roof_access = false 73var daedalus_roof_access = false
69var enable_gift_maps = [] 74var enable_gift_maps = []
70var enable_icarus = false 75var enable_icarus = false
71var endings_requirement = 0 76var endings_requirement = 0
77var fast_travel_access = 0
72var keyholder_sanity = false 78var keyholder_sanity = false
73var masteries_requirement = 0 79var masteries_requirement = 0
80var music_mapping = {}
74var port_pairings = {} 81var port_pairings = {}
82var rte_mapping = []
75var shuffle_control_center_colors = false 83var shuffle_control_center_colors = false
76var shuffle_doors = false 84var shuffle_doors = false
77var shuffle_gallery_paintings = false 85var shuffle_gallery_paintings = false
78var shuffle_letters = kSHUFFLE_LETTERS_VANILLA 86var shuffle_letters = kSHUFFLE_LETTERS_VANILLA
79var shuffle_symbols = false 87var shuffle_symbols = false
80var shuffle_worldports = false 88var shuffle_worldports = false
89var slot_rng = null
81var strict_cyan_ending = false 90var strict_cyan_ending = false
82var strict_purple_ending = false 91var strict_purple_ending = false
83var victory_condition = -1 92var victory_condition = -1
@@ -269,6 +278,13 @@ func _process_item(item, amount):
269 if item_id == gamedata.objects.get_special_ids()["Numbers"] and global.map == "the_fuzzy": 278 if item_id == gamedata.objects.get_special_ids()["Numbers"] and global.map == "the_fuzzy":
270 global.allow_numbers = true 279 global.allow_numbers = true
271 280
281 if gamedata.map_id_by_rte_ap_id.has(item_id):
282 var rteInner = get_tree().get_root().get_node_or_null(
283 "scene/player/pause_menu/menu/return/rteInner"
284 )
285 if rteInner != null:
286 rteInner.refreshButtons()
287
272 # Show a message about the item if it's new. 288 # Show a message about the item if it's new.
273 if int(item["index"]) > _last_new_item: 289 if int(item["index"]) > _last_new_item:
274 _last_new_item = int(item["index"]) 290 _last_new_item = int(item["index"])
@@ -463,12 +479,14 @@ func _client_connected(slot_data):
463 _last_new_item = localdata[0] 479 _last_new_item = localdata[0]
464 480
465 # Read slot data. 481 # Read slot data.
482 custom_mint_ending = slot_data.get("custom_mint_ending", "")
466 cyan_door_behavior = int(slot_data.get("cyan_door_behavior", 0)) 483 cyan_door_behavior = int(slot_data.get("cyan_door_behavior", 0))
467 daedalus_only = bool(slot_data.get("daedalus_only", false)) 484 daedalus_only = bool(slot_data.get("daedalus_only", false))
468 daedalus_roof_access = bool(slot_data.get("daedalus_roof_access", false)) 485 daedalus_roof_access = bool(slot_data.get("daedalus_roof_access", false))
469 enable_gift_maps = slot_data.get("enable_gift_maps", []) 486 enable_gift_maps = slot_data.get("enable_gift_maps", [])
470 enable_icarus = bool(slot_data.get("enable_icarus", false)) 487 enable_icarus = bool(slot_data.get("enable_icarus", false))
471 endings_requirement = int(slot_data.get("endings_requirement", 0)) 488 endings_requirement = int(slot_data.get("endings_requirement", 0))
489 fast_travel_access = int(slot_data.get("fast_travel_access", 0))
472 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false)) 490 keyholder_sanity = bool(slot_data.get("keyholder_sanity", false))
473 masteries_requirement = int(slot_data.get("masteries_requirement", 0)) 491 masteries_requirement = int(slot_data.get("masteries_requirement", 0))
474 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false)) 492 shuffle_control_center_colors = bool(slot_data.get("shuffle_control_center_colors", false))
@@ -496,6 +514,19 @@ func _client_connected(slot_data):
496 raw_pp[p1] 514 raw_pp[p1]
497 )] 515 )]
498 516
517 rte_mapping.clear()
518 if slot_data.has("rte"):
519 rte_mapping = slot_data.get("rte")
520
521 slot_rng = RandomNumberGenerator.new()
522 slot_rng.seed = int(slot_data.get("seed", 0))
523
524 music_mapping.clear()
525 if bool(slot_data.get("shuffle_music", false)):
526 for map_name in global.reserved_scenes:
527 var track_index = slot_rng.randi_range(0, musicPlayer.all_tracks.size() - 1)
528 music_mapping[map_name] = musicPlayer.all_tracks.keys()[track_index]
529
499 # Set up item locks. 530 # Set up item locks.
500 _item_locks = {} 531 _item_locks = {}
501 532
diff --git a/apworld/client/maps/control_center.gd b/apworld/client/maps/control_center.gd index fadfed9..8e919ab 100644 --- a/apworld/client/maps/control_center.gd +++ b/apworld/client/maps/control_center.gd
@@ -74,6 +74,61 @@ func on_map_load(root):
74 old_door.queue_free() 74 old_door.queue_free()
75 root.get_node("/root/scene/Components/Doors").add_child.call_deferred(new_door) 75 root.get_node("/root/scene/Components/Doors").add_child.call_deferred(new_door)
76 76
77 # Display White Ending requirements.
78 var ending_count = 0
79 var mastery_count = 0
80 for key in unlocks.data:
81 if unlocks.data[key] == "unlocked":
82 if key.ends_with("_ending") and key != "free_ending":
83 ending_count += 1
84 elif key.ends_with("_mastery"):
85 mastery_count += 1
86
87 var sign_prefab = preload("res://objects/nodes/sign.tscn")
88 var sign1 = sign_prefab.instantiate()
89 sign1.position = Vector3(87.5, 5, -42.01)
90 sign1.text = "Endings: %d/%d" % [ending_count, ap.endings_requirement]
91 root.get_node("/root/scene").add_child.call_deferred(sign1)
92
93 var sign2 = sign_prefab.instantiate()
94 sign2.position = Vector3(87.5, 5, -15.99)
95 sign2.rotation_degrees.y = 180
96 sign2.text = "Masteries: %d/%d" % [mastery_count, ap.masteries_requirement]
97 root.get_node("/root/scene").add_child.call_deferred(sign2)
98
99 # Handle custom Mint Ending.
100 if ap.custom_mint_ending != "":
101 var panel_prefab = preload("res://objects/nodes/panel.tscn")
102 var tpl_prefab = preload("res://objects/nodes/listeners/teleportListener.tscn")
103
104 var mint_ending = root.get_node("/root/scene/Components/Endings/mint_ending")
105
106 var mint_panel = panel_prefab.instantiate()
107 mint_panel.name = "mint_panel"
108 mint_panel.clue = ap.custom_mint_ending
109 mint_panel.symbol = ""
110 mint_panel.answer = ap.custom_mint_ending
111 mint_panel.position = Vector3(-63, 3, -29)
112 mint_panel.rotation_degrees = Vector3(-45, 90, 0)
113 root.get_node("/root/scene").add_child.call_deferred(mint_panel)
114
115 var mint_tpl = tpl_prefab.instantiate()
116 mint_tpl.name = "mint_tpl"
117 mint_tpl.teleport_point = mint_ending.position
118 mint_tpl.teleport_rotate = mint_ending.rotation_degrees
119 mint_tpl.target_path = mint_ending
120 mint_tpl.senders.append(NodePath("/root/scene/mint_panel"))
121 root.get_node("/root/scene").add_child.call_deferred(mint_tpl)
122
123 var mint_tpl2 = tpl_prefab.instantiate()
124 mint_tpl2.name = "mint_tpl2"
125 mint_tpl2.teleport_point = Vector3(0, -1000, 0)
126 mint_tpl2.target_path = mint_panel
127 mint_tpl2.senders.append(NodePath("/root/scene/mint_panel"))
128 root.get_node("/root/scene").add_child.call_deferred(mint_tpl2)
129
130 mint_ending.position.y = -1000
131
77 132
78func _set_up_mastery_listener(root, name): 133func _set_up_mastery_listener(root, name):
79 var prefab = preload("res://objects/nodes/listeners/unlockReaderListener.tscn") 134 var prefab = preload("res://objects/nodes/listeners/unlockReaderListener.tscn")
diff --git a/apworld/client/player.gd b/apworld/client/player.gd index 5fac9fd..dabc15d 100644 --- a/apworld/client/player.gd +++ b/apworld/client/player.gd
@@ -13,6 +13,8 @@ func _ready():
13 13
14 var ap = global.get_node("Archipelago") 14 var ap = global.get_node("Archipelago")
15 var gamedata = global.get_node("Gamedata") 15 var gamedata = global.get_node("Gamedata")
16 var map_id = gamedata.map_id_by_name.get(global.map)
17 var map_data = gamedata.objects.get_maps()[map_id]
16 18
17 compass = global.get_node("Compass") 19 compass = global.get_node("Compass")
18 compass.visible = ap.show_compass 20 compass.visible = ap.show_compass
@@ -26,8 +28,33 @@ func _ready():
26 28
27 ap.update_job_well_done_sign() 29 ap.update_job_well_done_sign()
28 30
31 # Set up the RTE trigger, if there is one.
32 if map_data.has_rte_trigger_pos():
33 var oneShotListener_prefab = preload("res://objects/nodes/listeners/oneShotListener.tscn")
34 var triggerArea_prefab = preload("res://objects/nodes/triggerArea.tscn")
35 var unlockSetterListener_prefab = preload(
36 "res://objects/nodes/listeners/unlockSetterListener.tscn"
37 )
38
39 var triggerArea = triggerArea_prefab.instantiate()
40 triggerArea.name = "rte_triggerArea"
41 triggerArea.position = gamedata.vec3d_to_vector3(map_data.get_rte_trigger_pos())
42 triggerArea.scale = gamedata.vec3d_to_vector3(map_data.get_rte_trigger_scale())
43 get_parent().add_child.call_deferred(triggerArea)
44
45 var osl = oneShotListener_prefab.instantiate()
46 osl.name = "rte_osl"
47 osl.senders.append(NodePath("/root/scene/rte_triggerArea"))
48 get_parent().add_child.call_deferred(osl)
49
50 var usl = unlockSetterListener_prefab.instantiate()
51 usl.name = "rte_usl"
52 usl.key = "rte_%s" % global.map
53 usl.value = "unlocked"
54 usl.senders.append(NodePath("/root/scene/rte_osl"))
55 get_parent().add_child.call_deferred(usl)
56
29 # Set up door locations. 57 # Set up door locations.
30 var map_id = gamedata.map_id_by_name.get(global.map)
31 for door in gamedata.objects.get_doors(): 58 for door in gamedata.objects.get_doors():
32 if door.get_map_id() != map_id: 59 if door.get_map_id() != map_id:
33 continue 60 continue
@@ -169,6 +196,17 @@ func _ready():
169 minimap.visible = ap.show_minimap 196 minimap.visible = ap.show_minimap
170 get_parent().add_child.call_deferred(minimap) 197 get_parent().add_child.call_deferred(minimap)
171 198
199 if ap.music_mapping.has(global.map):
200 var song_setter = get_node_or_null("/root/scene/songSetter")
201 if song_setter:
202 song_setter.song_name = ap.music_mapping[global.map]
203 else:
204 var song_setter_prefab = preload("res://objects/nodes/songSetter.tscn")
205 song_setter = song_setter_prefab.instantiate()
206 song_setter.name = "songSetter"
207 song_setter.song_name = ap.music_mapping[global.map]
208 get_parent().add_child.call_deferred(song_setter)
209
172 super._ready() 210 super._ready()
173 211
174 await get_tree().process_frame 212 await get_tree().process_frame
diff --git a/apworld/client/rteMenu.gd b/apworld/client/rteMenu.gd index 5882d77..519f09f 100644 --- a/apworld/client/rteMenu.gd +++ b/apworld/client/rteMenu.gd
@@ -1,5 +1,7 @@
1extends "res://scripts/ui/rteMenu.gd" 1extends "res://scripts/ui/rteMenu.gd"
2 2
3var buttons = []
4
3 5
4func _readier(): 6func _readier():
5 var ap = global.get_node("Archipelago") 7 var ap = global.get_node("Archipelago")
@@ -8,5 +10,58 @@ func _readier():
8 get_node("rte_daedalus").show() 10 get_node("rte_daedalus").show()
9 11
10 switcher.preload_map("res://objects/scenes/daedalus.tscn") 12 switcher.preload_map("res://objects/scenes/daedalus.tscn")
13 elif !ap.rte_mapping.is_empty():
14 buttons = [$rte_the_plaza, $rte_the_gallery, $rte_daedalus, $rte_control_center]
15 for i in range(4):
16 buttons[i].name = "button_%d" % i
17 for i in range(4):
18 _setupButton(buttons[i], ap.rte_mapping[i])
19
20 refreshButtons()
11 else: 21 else:
12 super()._readier() 22 super()._readier()
23
24
25func _setupButton(button, map_name):
26 switcher.preload_map("res://objects/scenes/%s.tscn" % map_name)
27
28 button.hide()
29 button.text = map_name.replace("_", " ")
30 button.name = "rte_%s" % map_name
31 button.autowrap_mode = TextServer.AUTOWRAP_WORD
32
33 var ap = global.get_node("Archipelago")
34 if (
35 ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_VANILLA
36 and !unlocks.data.has("rte_%s" % map_name)
37 ):
38 unlocks.data["rte_%s" % map_name] = ""
39
40
41func refreshButtons():
42 var ap = global.get_node("Archipelago")
43 if ap.rte_mapping.is_empty():
44 return
45
46 for i in range(4):
47 if _shouldShowButton(ap.rte_mapping[i]):
48 buttons[i].show()
49 else:
50 buttons[i].hide()
51
52
53func _shouldShowButton(map_name):
54 var ap = global.get_node("Archipelago")
55
56 if ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_VANILLA:
57 return unlocks.data["rte_%s" % map_name] == "unlocked"
58 elif ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_UNLOCKED:
59 return true
60 elif ap.fast_travel_access == ap.kFAST_TRAVEL_ACCESS_ITEMS:
61 var gamedata = global.get_node("Gamedata")
62 var map_id = gamedata.map_id_by_name[map_name]
63 var rte_ap_id = gamedata.objects.get_maps()[map_id].get_rte_ap_id()
64
65 return ap.client.hasItem(rte_ap_id)
66
67 return false
diff --git a/apworld/items.py b/apworld/items.py index 28158c3..143ccb1 100644 --- a/apworld/items.py +++ b/apworld/items.py
@@ -5,6 +5,8 @@ from BaseClasses import Item
5class Lingo2Item(Item): 5class Lingo2Item(Item):
6 game: str = "Lingo 2" 6 game: str = "Lingo 2"
7 7
8 is_letter: bool
9
8 10
9SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = { 11SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = {
10 data_pb2.PuzzleSymbol.SUN: "Sun Symbol", 12 data_pb2.PuzzleSymbol.SUN: "Sun Symbol",
@@ -28,4 +30,5 @@ SYMBOL_ITEMS: dict[data_pb2.PuzzleSymbol, str] = {
28 data_pb2.PuzzleSymbol.QUESTION: "Question Symbol", 30 data_pb2.PuzzleSymbol.QUESTION: "Question Symbol",
29} 31}
30 32
31ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"] 33ALL_LETTERS_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
34ANTI_COLLECTABLE_TRAPS: list[str] = [f"Anti {letter}" for letter in ALL_LETTERS_UPPER]
diff --git a/apworld/locations.py b/apworld/locations.py index 3d619dc..c92215e 100644 --- a/apworld/locations.py +++ b/apworld/locations.py
@@ -1,8 +1,112 @@
1from BaseClasses import Location 1from enum import Enum
2from typing import TYPE_CHECKING
3
4from BaseClasses import Location, Item, Region, CollectionState, Entrance
5from .items import Lingo2Item
6from .rules import AccessRequirements
7
8if TYPE_CHECKING:
9 from . import Lingo2World
10
11
12class LetterPlacementType(Enum):
13 ANY = 0
14 DISALLOW = 1
15 FORCE = 2
16
17
18def get_required_regions(reqs: AccessRequirements, world: "Lingo2World",
19 regions: dict[str, Region] | None) -> list[Region]:
20 # Replace required rooms with regions for the top level requirement, which saves looking up the regions during rule
21 # checking.
22 if regions is not None:
23 return [regions[room_name] for room_name in reqs.rooms]
24 else:
25 return [world.multiworld.get_region(room_name, world.player) for room_name in reqs.rooms]
2 26
3 27
4class Lingo2Location(Location): 28class Lingo2Location(Location):
5 game: str = "Lingo 2" 29 game: str = "Lingo 2"
6 30
31 reqs: AccessRequirements | None
32 world: "Lingo2World"
33 required_regions: list[Region]
34
7 port_id: int 35 port_id: int
8 goal: bool 36 goal: bool
37 letter_placement_type: LetterPlacementType
38
39 @classmethod
40 def non_event_location(cls, world: "Lingo2World", code: int, region: Region):
41 result = cls(world.player, world.static_logic.location_id_to_name[code], code, region)
42 result.reqs = None
43 result.world = world
44 result.required_regions = []
45
46 return result
47
48 @classmethod
49 def event_location(cls, world: "Lingo2World", name: str, region: Region):
50 result = cls(world.player, name, None, region)
51 result.reqs = None
52 result.world = world
53 result.required_regions = []
54
55 return result
56
57 def set_access_rule(self, reqs: AccessRequirements, regions: dict[str, Region] | None):
58 self.reqs = reqs
59 self.required_regions = get_required_regions(reqs, self.world, regions)
60 self.access_rule = self._l2_access_rule
61
62 def _l2_access_rule(self, state: CollectionState) -> bool:
63 if self.reqs is not None and not self.reqs.check_access(state, self.world):
64 return False
65
66 if not all(state.can_reach(region) for region in self.required_regions):
67 return False
68
69 return True
70
71 def set_up_letter_rule(self, lpt: LetterPlacementType):
72 self.letter_placement_type = lpt
73 self.item_rule = self._l2_item_rule
74
75 def _l2_item_rule(self, item: Item) -> bool:
76 if not isinstance(item, Lingo2Item):
77 return True
78
79 if self.letter_placement_type == LetterPlacementType.FORCE:
80 return item.is_letter
81 elif self.letter_placement_type == LetterPlacementType.DISALLOW:
82 return not item.is_letter
83
84 return True
85
86
87class Lingo2Entrance(Entrance):
88 reqs: AccessRequirements | None
89 world: "Lingo2World"
90 required_regions: list[Region]
91
92 def __init__(self, world: "Lingo2World", description: str, region: Region):
93 super().__init__(world.player, description, region)
94
95 self.reqs = None
96 self.world = world
97 self.required_regions = []
98
99 def set_access_rule(self, reqs: AccessRequirements, regions: dict[str, Region] | None):
100 self.reqs = reqs
101 self.required_regions = get_required_regions(reqs, self.world, regions)
102 self.access_rule = self._l2_access_rule
103
104 def _l2_access_rule(self, state: CollectionState) -> bool:
105 if self.reqs is not None and not self.reqs.check_access(state, self.world):
106 return False
107
108 if not all(state.can_reach(region) for region in self.required_regions):
109 return False
110
111 return True
112
diff --git a/apworld/options.py b/apworld/options.py index 5661351..c1eab33 100644 --- a/apworld/options.py +++ b/apworld/options.py
@@ -1,6 +1,6 @@
1from dataclasses import dataclass 1from dataclasses import dataclass
2 2
3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range, OptionSet 3from Options import PerGameCommonOptions, Toggle, Choice, DefaultOnToggle, Range, OptionSet, FreeText
4 4
5 5
6class ShuffleDoors(DefaultOnToggle): 6class ShuffleDoors(DefaultOnToggle):
@@ -44,6 +44,17 @@ class ShuffleLetters(Choice):
44 option_item_cyan = 4 44 option_item_cyan = 4
45 45
46 46
47class RestrictLetterPlacements(Toggle):
48 """
49 If enabled, letter items will be shuffled among letter locations in your local world. Shuffle Letters must be set to
50 Progressive or Item Cyan for this to be useful.
51
52 WARNING: This option may slow down generation. Additionally, it is only reliable with Shuffle Letters set to Item
53 Cyan. When set to Progressive, Shuffle Doors and Shuffle Symbols must be turned off.
54 """
55 display_name = "Restrict Letter Placements"
56
57
47class ShuffleSymbols(Toggle): 58class ShuffleSymbols(Toggle):
48 """ 59 """
49 If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel. 60 If enabled, 19 items will be added to the pool, representing the different symbols that can appear on a panel.
@@ -91,6 +102,27 @@ class CyanDoorBehavior(Choice):
91 option_item = 2 102 option_item = 2
92 103
93 104
105class ShuffleFastTravel(Toggle):
106 """If enabled, the list of maps you can fast travel to is randomized, except for The Entry, which is always
107 accessible."""
108 display_name = "Shuffle Fast Travel"
109
110
111class FastTravelAccess(Choice):
112 """
113 Controls how the fast travel buttons on the pause menu work.
114
115 - **Vanilla**: You can only fast travel to maps once you have been to them and stepped foot in the general area that
116 the warp would place you. This option means that fast travel has no impact on logic.
117 - **Unlocked**: All five fast travel maps will be available from the start.
118 - **Items**: Only The Entry is available from the start. The other fast travel buttons are locked behind items.
119 """
120 display_name = "Fast Travel Access"
121 option_vanilla = 0
122 option_unlocked = 1
123 option_items = 2
124
125
94class EnableIcarus(Toggle): 126class EnableIcarus(Toggle):
95 """ 127 """
96 Controls whether Icarus is randomized. If disabled, which is the default, no locations or items will be created for 128 Controls whether Icarus is randomized. If disabled, which is the default, no locations or items will be created for
@@ -146,6 +178,15 @@ class DaedalusRoofAccess(Toggle):
146 display_name = "Allow Daedalus Roof Access" 178 display_name = "Allow Daedalus Roof Access"
147 179
148 180
181class CustomMintEnding(FreeText):
182 """
183 If not blank, this will add a new panel that must be solved before collecting Mint Ending (EXIT in the Control
184 Center). The panel will only require typing the text provided for this option, which means the choice of letters
185 here has an impact on logic.
186 """
187 display_name = "Custom Mint Ending"
188
189
149class StrictPurpleEnding(DefaultOnToggle): 190class StrictPurpleEnding(DefaultOnToggle):
150 """ 191 """
151 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending. 192 If enabled, the player will be required to have all purple (level 1) letters in order to get Purple Ending.
@@ -224,23 +265,35 @@ class TrapPercentage(Range):
224 default = 0 265 default = 0
225 266
226 267
268class ShuffleMusic(Toggle):
269 """
270 If enabled, every map will be assigned a random music track.
271 """
272 display_name = "Shuffle Music"
273
274
227@dataclass 275@dataclass
228class Lingo2Options(PerGameCommonOptions): 276class Lingo2Options(PerGameCommonOptions):
229 shuffle_doors: ShuffleDoors 277 shuffle_doors: ShuffleDoors
230 shuffle_control_center_colors: ShuffleControlCenterColors 278 shuffle_control_center_colors: ShuffleControlCenterColors
231 shuffle_gallery_paintings: ShuffleGalleryPaintings 279 shuffle_gallery_paintings: ShuffleGalleryPaintings
232 shuffle_letters: ShuffleLetters 280 shuffle_letters: ShuffleLetters
281 restrict_letter_placements: RestrictLetterPlacements
233 shuffle_symbols: ShuffleSymbols 282 shuffle_symbols: ShuffleSymbols
234 shuffle_worldports: ShuffleWorldports 283 shuffle_worldports: ShuffleWorldports
235 keyholder_sanity: KeyholderSanity 284 keyholder_sanity: KeyholderSanity
236 cyan_door_behavior: CyanDoorBehavior 285 cyan_door_behavior: CyanDoorBehavior
286 shuffle_fast_travel: ShuffleFastTravel
287 fast_travel_access: FastTravelAccess
237 enable_icarus: EnableIcarus 288 enable_icarus: EnableIcarus
238 enable_gift_maps: EnableGiftMaps 289 enable_gift_maps: EnableGiftMaps
239 daedalus_only: DaedalusOnly 290 daedalus_only: DaedalusOnly
240 daedalus_roof_access: DaedalusRoofAccess 291 daedalus_roof_access: DaedalusRoofAccess
292 custom_mint_ending: CustomMintEnding
241 strict_purple_ending: StrictPurpleEnding 293 strict_purple_ending: StrictPurpleEnding
242 strict_cyan_ending: StrictCyanEnding 294 strict_cyan_ending: StrictCyanEnding
243 victory_condition: VictoryCondition 295 victory_condition: VictoryCondition
244 endings_requirement: EndingsRequirement 296 endings_requirement: EndingsRequirement
245 masteries_requirement: MasteriesRequirement 297 masteries_requirement: MasteriesRequirement
246 trap_percentage: TrapPercentage 298 trap_percentage: TrapPercentage
299 shuffle_music: ShuffleMusic
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index b946296..2c3e08b 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -5,7 +5,8 @@ from .generated import data_pb2 as data_pb2
5from .items import SYMBOL_ITEMS 5from .items import SYMBOL_ITEMS
6from typing import TYPE_CHECKING, NamedTuple 6from typing import TYPE_CHECKING, NamedTuple
7 7
8from .options import ShuffleLetters, CyanDoorBehavior, VictoryCondition 8from .options import ShuffleLetters, CyanDoorBehavior, VictoryCondition, FastTravelAccess
9from .rules import AccessRequirements
9 10
10if TYPE_CHECKING: 11if TYPE_CHECKING:
11 from . import Lingo2World 12 from . import Lingo2World
@@ -21,177 +22,10 @@ def calculate_letter_histogram(solution: str) -> dict[str, int]:
21 return histogram 22 return histogram
22 23
23 24
24class AccessRequirements:
25 items: set[str]
26 progressives: dict[str, int]
27 rooms: set[str]
28 letters: dict[str, int]
29 cyans: bool
30
31 # This is an AND of ORs.
32 or_logic: list[list["AccessRequirements"]]
33
34 # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
35 # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
36 complete_at: int | None
37 possibilities: list["AccessRequirements"]
38
39 def __init__(self):
40 self.items = set()
41 self.progressives = dict()
42 self.rooms = set()
43 self.letters = dict()
44 self.cyans = False
45 self.or_logic = list()
46 self.complete_at = None
47 self.possibilities = list()
48
49 def copy(self) -> "AccessRequirements":
50 reqs = AccessRequirements()
51 reqs.items = self.items.copy()
52 reqs.progressives = self.progressives.copy()
53 reqs.rooms = self.rooms.copy()
54 reqs.letters = self.letters.copy()
55 reqs.cyans = self.cyans
56 reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
57 reqs.complete_at = self.complete_at
58 reqs.possibilities = self.possibilities.copy()
59 return reqs
60
61 def merge(self, other: "AccessRequirements"):
62 for item in other.items:
63 self.items.add(item)
64
65 for item, amount in other.progressives.items():
66 self.progressives[item] = max(amount, self.progressives.get(item, 0))
67
68 for room in other.rooms:
69 self.rooms.add(room)
70
71 for letter, level in other.letters.items():
72 self.letters[letter] = max(self.letters.get(letter, 0), level)
73
74 self.cyans = self.cyans or other.cyans
75
76 for disjunction in other.or_logic:
77 self.or_logic.append([sub_req.copy() for sub_req in disjunction])
78
79 if other.complete_at is not None:
80 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
81 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
82 # conjunctions of requirements.
83 if self.complete_at is not None:
84 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
85
86 left_req = AccessRequirements()
87 left_req.complete_at = self.complete_at
88 left_req.possibilities = [sub_req.copy() for sub_req in self.possibilities]
89 self.or_logic.append([left_req])
90
91 self.complete_at = None
92 self.possibilities = list()
93
94 right_req = AccessRequirements()
95 right_req.complete_at = other.complete_at
96 right_req.possibilities = [sub_req.copy() for sub_req in other.possibilities]
97 self.or_logic.append([right_req])
98 else:
99 self.complete_at = other.complete_at
100 self.possibilities = [sub_req.copy() for sub_req in other.possibilities]
101
102 def is_empty(self) -> bool:
103 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
104 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
105
106 def __eq__(self, other: "AccessRequirements"):
107 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
108 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
109 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
110
111 def simplify(self):
112 resimplify = False
113
114 if len(self.or_logic) > 0:
115 old_or_logic = self.or_logic
116
117 def remove_redundant(sub_reqs: "AccessRequirements"):
118 new_reqs = sub_reqs.copy()
119 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
120 if new_reqs != sub_reqs:
121 return new_reqs
122 else:
123 return sub_reqs
124
125 self.or_logic = []
126 for disjunction in old_or_logic:
127 new_disjunction = []
128 for ssr in disjunction:
129 new_ssr = remove_redundant(ssr)
130 if not new_ssr.is_empty():
131 new_disjunction.append(new_ssr)
132 else:
133 new_disjunction.clear()
134 break
135 if len(new_disjunction) == 1:
136 self.merge(new_disjunction[0])
137 resimplify = True
138 elif len(new_disjunction) > 1:
139 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
140 self.merge(new_disjunction[0])
141 resimplify = True
142 else:
143 self.or_logic.append(new_disjunction)
144
145 if resimplify:
146 self.simplify()
147
148 def get_referenced_rooms(self):
149 result = set(self.rooms)
150
151 for disjunction in self.or_logic:
152 for sub_req in disjunction:
153 result = result.union(sub_req.get_referenced_rooms())
154
155 for sub_req in self.possibilities:
156 result = result.union(sub_req.get_referenced_rooms())
157
158 return result
159
160 def remove_room(self, room: str):
161 if room in self.rooms:
162 self.rooms.remove(room)
163
164 for disjunction in self.or_logic:
165 for sub_req in disjunction:
166 sub_req.remove_room(room)
167
168 for sub_req in self.possibilities:
169 sub_req.remove_room(room)
170
171 def __repr__(self):
172 parts = []
173 if len(self.items) > 0:
174 parts.append(f"items={self.items}")
175 if len(self.progressives) > 0:
176 parts.append(f"progressives={self.progressives}")
177 if len(self.rooms) > 0:
178 parts.append(f"rooms={self.rooms}")
179 if len(self.letters) > 0:
180 parts.append(f"letters={self.letters}")
181 if self.cyans:
182 parts.append(f"cyans=True")
183 if len(self.or_logic) > 0:
184 parts.append(f"or_logic={self.or_logic}")
185 if self.complete_at is not None:
186 parts.append(f"complete_at={self.complete_at}")
187 if len(self.possibilities) > 0:
188 parts.append(f"possibilities={self.possibilities}")
189 return "AccessRequirements(" + ", ".join(parts) + ")"
190
191
192class PlayerLocation(NamedTuple): 25class PlayerLocation(NamedTuple):
193 code: int | None 26 code: int | None
194 reqs: AccessRequirements 27 reqs: AccessRequirements
28 is_letter: bool = False
195 29
196 30
197class LetterBehavior(IntEnum): 31class LetterBehavior(IntEnum):
@@ -221,6 +55,8 @@ class Lingo2PlayerLogic:
221 55
222 double_letter_amount: dict[str, int] 56 double_letter_amount: dict[str, int]
223 goal_room_id: int 57 goal_room_id: int
58 rte_mapping: list[int]
59 custom_mint_ending: str | None
224 60
225 def __init__(self, world: "Lingo2World"): 61 def __init__(self, world: "Lingo2World"):
226 self.world = world 62 self.world = world
@@ -235,6 +71,7 @@ class Lingo2PlayerLogic:
235 self.real_items = list() 71 self.real_items = list()
236 self.starting_items = list() 72 self.starting_items = list()
237 self.double_letter_amount = dict() 73 self.double_letter_amount = dict()
74 self.custom_mint_ending = None
238 75
239 def should_shuffle_map(game_map) -> bool | set[int]: 76 def should_shuffle_map(game_map) -> bool | set[int]:
240 if world.options.daedalus_only: 77 if world.options.daedalus_only:
@@ -294,6 +131,18 @@ class Lingo2PlayerLogic:
294 self.shuffled_doors.update(set(door.id for door in world.static_logic.objects.doors 131 self.shuffled_doors.update(set(door.id for door in world.static_logic.objects.doors
295 if door.map_id == game_map.id and door.daedalus_only_allow)) 132 if door.map_id == game_map.id and door.daedalus_only_allow))
296 133
134 if (world.options.restrict_letter_placements
135 and world.options.shuffle_letters == ShuffleLetters.option_progressive
136 and (world.options.shuffle_doors or world.options.shuffle_symbols)):
137 raise OptionError(f"When Restrict Letter Placements is enabled and Shuffle Letters is set to Progressive, "
138 f"both Shuffle Doors and Shuffle Symbols must be disabled (Player {world.player}).")
139
140 if world.options.custom_mint_ending.value != "":
141 self.custom_mint_ending = ''.join(filter(str.isalpha, world.options.custom_mint_ending.value)).lower()
142
143 if len(self.custom_mint_ending) > 52:
144 raise OptionError(f"Custom Mint Ending should not be greater than 52 letters (Player {world.player}).")
145
297 maximum_masteries = 13 + len(world.options.enable_gift_maps.value) 146 maximum_masteries = 13 + len(world.options.enable_gift_maps.value)
298 if world.options.enable_icarus: 147 if world.options.enable_icarus:
299 maximum_masteries += 1 148 maximum_masteries += 1
@@ -304,6 +153,19 @@ class Lingo2PlayerLogic:
304 if "The Fuzzy" in world.options.enable_gift_maps.value: 153 if "The Fuzzy" in world.options.enable_gift_maps.value:
305 self.real_items.append("Numbers") 154 self.real_items.append("Numbers")
306 155
156 if world.options.shuffle_fast_travel:
157 travelable_maps = [map_id for map_id in self.shuffled_maps
158 if world.static_logic.objects.maps[map_id].HasField("rte_room")]
159 self.rte_mapping = world.random.sample(travelable_maps, 4)
160 else:
161 canonical_rtes = ["the_plaza", "the_gallery", "daedalus", "control_center"]
162 self.rte_mapping = [world.static_logic.map_id_by_name[map_name] for map_name in canonical_rtes
163 if world.static_logic.map_id_by_name[map_name] in self.shuffled_maps]
164
165 if world.options.fast_travel_access == FastTravelAccess.option_items:
166 for rte_map in self.rte_mapping:
167 self.real_items.append(world.static_logic.get_map_rte_item_name(rte_map))
168
307 if self.world.options.shuffle_doors: 169 if self.world.options.shuffle_doors:
308 for progressive in world.static_logic.objects.progressives: 170 for progressive in world.static_logic.objects.progressives:
309 for i in range(0, len(progressive.doors)): 171 for i in range(0, len(progressive.doors)):
@@ -392,9 +254,11 @@ class Lingo2PlayerLogic:
392 if not self.should_shuffle_room(letter.room_id): 254 if not self.should_shuffle_room(letter.room_id):
393 continue 255 continue
394 256
395 self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
396 AccessRequirements()))
397 behavior = self.get_letter_behavior(letter.key, letter.level2) 257 behavior = self.get_letter_behavior(letter.key, letter.level2)
258
259 self.locations_by_room.setdefault(letter.room_id, []).append(
260 PlayerLocation(letter.ap_id, AccessRequirements(), behavior == LetterBehavior.ITEM))
261
398 if behavior == LetterBehavior.VANILLA: 262 if behavior == LetterBehavior.VANILLA:
399 if not world.for_tracker: 263 if not world.for_tracker:
400 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 264 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
diff --git a/apworld/regions.py b/apworld/regions.py index 2f9b571..313fd02 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -4,9 +4,9 @@ import BaseClasses
4from BaseClasses import Region, ItemClassification, Entrance 4from BaseClasses import Region, ItemClassification, Entrance
5from entrance_rando import randomize_entrances 5from entrance_rando import randomize_entrances
6from .items import Lingo2Item 6from .items import Lingo2Item
7from .locations import Lingo2Location 7from .locations import Lingo2Location, LetterPlacementType, Lingo2Entrance
8from .options import FastTravelAccess
8from .player_logic import AccessRequirements 9from .player_logic import AccessRequirements
9from .rules import make_location_lambda
10 10
11if TYPE_CHECKING: 11if TYPE_CHECKING:
12 from . import Lingo2World 12 from . import Lingo2World
@@ -21,13 +21,17 @@ def create_locations(room, new_region: Region, world: "Lingo2World", regions: di
21 reqs = location.reqs.copy() 21 reqs = location.reqs.copy()
22 reqs.remove_room(new_region.name) 22 reqs.remove_room(new_region.name)
23 23
24 new_location = Lingo2Location(world.player, world.static_logic.location_id_to_name[location.code], 24 new_location = Lingo2Location.non_event_location(world, location.code, new_region)
25 location.code, new_region) 25 new_location.set_access_rule(reqs, regions)
26 new_location.access_rule = make_location_lambda(reqs, world, regions) 26 if world.options.restrict_letter_placements:
27 if location.is_letter:
28 new_location.set_up_letter_rule(LetterPlacementType.FORCE)
29 else:
30 new_location.set_up_letter_rule(LetterPlacementType.DISALLOW)
27 new_region.locations.append(new_location) 31 new_region.locations.append(new_location)
28 32
29 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items(): 33 for event_name, item_name in world.player_logic.event_loc_item_by_room.get(room.id, {}).items():
30 new_location = Lingo2Location(world.player, event_name, None, new_region) 34 new_location = Lingo2Location.event_location(world, event_name, new_region)
31 if world.for_tracker and item_name == "Victory": 35 if world.for_tracker and item_name == "Victory":
32 new_location.goal = True 36 new_location.goal = True
33 37
@@ -41,12 +45,11 @@ def create_locations(room, new_region: Region, world: "Lingo2World", regions: di
41 if port.no_shuffle: 45 if port.no_shuffle:
42 continue 46 continue
43 47
44 new_location = Lingo2Location(world.player, f"Worldport {port.id} Entered", None, new_region) 48 new_location = Lingo2Location.event_location(world, f"Worldport {port.id} Entered", new_region)
45 new_location.port_id = port.id 49 new_location.port_id = port.id
46 50
47 if port.HasField("required_door"): 51 if port.HasField("required_door"):
48 new_location.access_rule = \ 52 new_location.set_access_rule(world.player_logic.get_door_open_reqs(port.required_door), regions)
49 make_location_lambda(world.player_logic.get_door_open_reqs(port.required_door), world, regions)
50 53
51 new_region.locations.append(new_location) 54 new_region.locations.append(new_location)
52 55
@@ -135,6 +138,10 @@ def create_regions(world: "Lingo2World"):
135 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending: 138 if connection.HasField("cyan_ending") and connection.cyan_ending and world.options.strict_cyan_ending:
136 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz") 139 world.player_logic.add_solution_reqs(reqs, "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz")
137 140
141 if (connection.HasField("mint_ending") and connection.mint_ending
142 and world.player_logic.custom_mint_ending is not None):
143 world.player_logic.add_solution_reqs(reqs, world.player_logic.custom_mint_ending)
144
138 reqs.simplify() 145 reqs.simplify()
139 reqs.remove_room(from_region) 146 reqs.remove_room(from_region)
140 147
@@ -144,8 +151,8 @@ def create_regions(world: "Lingo2World"):
144 # what regions are dead ends. 151 # what regions are dead ends.
145 continue 152 continue
146 153
147 connection = Entrance(world.player, connection_name, regions[from_region]) 154 connection = Lingo2Entrance(world, connection_name, regions[from_region])
148 connection.access_rule = make_location_lambda(reqs, world, regions) 155 connection.set_access_rule(reqs, regions)
149 156
150 regions[from_region].exits.append(connection) 157 regions[from_region].exits.append(connection)
151 connection.connect(regions[to_region]) 158 connection.connect(regions[to_region])
@@ -153,6 +160,25 @@ def create_regions(world: "Lingo2World"):
153 for region in reqs.get_referenced_rooms(): 160 for region in reqs.get_referenced_rooms():
154 world.multiworld.register_indirect_condition(regions[region], connection) 161 world.multiworld.register_indirect_condition(regions[region], connection)
155 162
163 if world.options.fast_travel_access != FastTravelAccess.option_vanilla:
164 for rte_map_id in world.player_logic.rte_mapping:
165 rte_map = world.static_logic.objects.maps[rte_map_id]
166 to_region = world.static_logic.get_room_region_name(rte_map.rte_room)
167
168 if to_region not in regions:
169 continue
170
171 connection_name = f"Return to {to_region}"
172 connection = Lingo2Entrance(world, connection_name, regions["Menu"])
173 regions["Menu"].exits.append(connection)
174 connection.connect(regions[to_region])
175
176 if world.options.fast_travel_access == FastTravelAccess.option_items:
177 reqs = AccessRequirements()
178 reqs.items.add(world.static_logic.get_map_rte_item_name(rte_map_id))
179
180 connection.set_access_rule(reqs, regions)
181
156 world.multiworld.regions += regions.values() 182 world.multiworld.regions += regions.values()
157 183
158 184
@@ -184,13 +210,13 @@ def shuffle_entrances(world: "Lingo2World"):
184 from_region = world.multiworld.get_region(from_region_name, world.player) 210 from_region = world.multiworld.get_region(from_region_name, world.player)
185 to_region = world.multiworld.get_region(to_region_name, world.player) 211 to_region = world.multiworld.get_region(to_region_name, world.player)
186 212
187 connection = Entrance(world.player, f"{from_region_name} - {chosen_port.display_name}", from_region) 213 connection = Lingo2Entrance(world, f"{from_region_name} - {chosen_port.display_name}", from_region)
188 from_region.exits.append(connection) 214 from_region.exits.append(connection)
189 connection.connect(to_region) 215 connection.connect(to_region)
190 216
191 if chosen_port.HasField("required_door"): 217 if chosen_port.HasField("required_door"):
192 door_reqs = world.player_logic.get_door_open_reqs(chosen_port.required_door) 218 door_reqs = world.player_logic.get_door_open_reqs(chosen_port.required_door)
193 connection.access_rule = make_location_lambda(door_reqs, world, None) 219 connection.set_access_rule(door_reqs, None)
194 220
195 for region in door_reqs.get_referenced_rooms(): 221 for region in door_reqs.get_referenced_rooms():
196 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), 222 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
@@ -206,12 +232,13 @@ def shuffle_entrances(world: "Lingo2World"):
206 entrance = port_region.create_er_target(connection_name) 232 entrance = port_region.create_er_target(connection_name)
207 entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY 233 entrance.randomization_type = BaseClasses.EntranceType.TWO_WAY
208 234
209 er_exit = port_region.create_exit(connection_name) 235 er_exit = Lingo2Entrance(world, connection_name, port_region)
236 port_region.exits.append(er_exit)
210 er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY 237 er_exit.randomization_type = BaseClasses.EntranceType.TWO_WAY
211 238
212 if port.HasField("required_door"): 239 if port.HasField("required_door"):
213 door_reqs = world.player_logic.get_door_open_reqs(port.required_door) 240 door_reqs = world.player_logic.get_door_open_reqs(port.required_door)
214 er_exit.access_rule = make_location_lambda(door_reqs, world, None) 241 er_exit.set_access_rule(door_reqs, None)
215 242
216 for region in door_reqs.get_referenced_rooms(): 243 for region in door_reqs.get_referenced_rooms():
217 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), 244 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
@@ -238,7 +265,7 @@ def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
238 from_region = world.multiworld.get_region(from_region_name, world.player) 265 from_region = world.multiworld.get_region(from_region_name, world.player)
239 to_region = world.multiworld.get_region(to_region_name, world.player) 266 to_region = world.multiworld.get_region(to_region_name, world.player)
240 267
241 connection = Entrance(world.player, f"{from_region_name} - {from_port.display_name}", from_region) 268 connection = Lingo2Entrance(world, f"{from_region_name} - {from_port.display_name}", from_region)
242 269
243 reqs = AccessRequirements() 270 reqs = AccessRequirements()
244 if from_port.HasField("required_door"): 271 if from_port.HasField("required_door"):
@@ -248,7 +275,7 @@ def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
248 reqs.items.add(f"Worldport {fpid} Entered") 275 reqs.items.add(f"Worldport {fpid} Entered")
249 276
250 if not reqs.is_empty(): 277 if not reqs.is_empty():
251 connection.access_rule = make_location_lambda(reqs, world, None) 278 connection.set_access_rule(reqs, None)
252 279
253 for region in reqs.get_referenced_rooms(): 280 for region in reqs.get_referenced_rooms():
254 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), 281 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
diff --git a/apworld/rules.py b/apworld/rules.py index f859e75..70a76c0 100644 --- a/apworld/rules.py +++ b/apworld/rules.py
@@ -1,66 +1,215 @@
1from collections.abc import Callable
2from typing import TYPE_CHECKING 1from typing import TYPE_CHECKING
3 2
4from BaseClasses import CollectionState, Region 3from BaseClasses import CollectionState
5from .player_logic import AccessRequirements
6 4
7if TYPE_CHECKING: 5if TYPE_CHECKING:
8 from . import Lingo2World 6 from . import Lingo2World
9 7
10 8
11def lingo2_can_satisfy_requirements(state: CollectionState, reqs: AccessRequirements, regions: list[Region], 9class AccessRequirements:
12 world: "Lingo2World") -> bool: 10 items: set[str]
13 if not all(state.has(item, world.player) for item in reqs.items): 11 progressives: dict[str, int]
14 return False 12 rooms: set[str]
13 letters: dict[str, int]
14 cyans: bool
15 15
16 if not all(state.has(item, world.player, amount) for item, amount in reqs.progressives.items()): 16 # This is an AND of ORs.
17 return False 17 or_logic: list[list["AccessRequirements"]]
18 18
19 if not all(state.can_reach_region(region_name, world.player) for region_name in reqs.rooms): 19 # When complete_at is set, at least that many of the requirements in possibilities must be accessible. This should
20 return False 20 # only be used for doors with complete_at > 1, as or_logic is more efficient for complete_at == 1.
21 complete_at: int | None
22 possibilities: list["AccessRequirements"]
21 23
22 if not all(state.can_reach(region) for region in regions): 24 def __init__(self):
23 return False 25 self.items = set()
26 self.progressives = dict()
27 self.rooms = set()
28 self.letters = dict()
29 self.cyans = False
30 self.or_logic = list()
31 self.complete_at = None
32 self.possibilities = list()
24 33
25 for letter_key, letter_level in reqs.letters.items(): 34 def copy(self) -> "AccessRequirements":
26 if not state.has(letter_key, world.player, letter_level): 35 reqs = AccessRequirements()
36 reqs.items = self.items.copy()
37 reqs.progressives = self.progressives.copy()
38 reqs.rooms = self.rooms.copy()
39 reqs.letters = self.letters.copy()
40 reqs.cyans = self.cyans
41 reqs.or_logic = [[other_req.copy() for other_req in disjunction] for disjunction in self.or_logic]
42 reqs.complete_at = self.complete_at
43 reqs.possibilities = self.possibilities.copy()
44 return reqs
45
46 def merge(self, other: "AccessRequirements"):
47 for item in other.items:
48 self.items.add(item)
49
50 for item, amount in other.progressives.items():
51 self.progressives[item] = max(amount, self.progressives.get(item, 0))
52
53 for room in other.rooms:
54 self.rooms.add(room)
55
56 for letter, level in other.letters.items():
57 self.letters[letter] = max(self.letters.get(letter, 0), level)
58
59 self.cyans = self.cyans or other.cyans
60
61 for disjunction in other.or_logic:
62 self.or_logic.append([sub_req.copy() for sub_req in disjunction])
63
64 if other.complete_at is not None:
65 # Merging multiple requirements that use complete_at sucks, and is part of why we want to minimize use of
66 # it. If both requirements use complete_at, we will cheat by using the or_logic field, which supports
67 # conjunctions of requirements.
68 if self.complete_at is not None:
69 print("Merging requirements with complete_at > 1. This is messy and should be avoided!")
70
71 left_req = AccessRequirements()
72 left_req.complete_at = self.complete_at
73 left_req.possibilities = [sub_req.copy() for sub_req in self.possibilities]
74 self.or_logic.append([left_req])
75
76 self.complete_at = None
77 self.possibilities = list()
78
79 right_req = AccessRequirements()
80 right_req.complete_at = other.complete_at
81 right_req.possibilities = [sub_req.copy() for sub_req in other.possibilities]
82 self.or_logic.append([right_req])
83 else:
84 self.complete_at = other.complete_at
85 self.possibilities = [sub_req.copy() for sub_req in other.possibilities]
86
87 def is_empty(self) -> bool:
88 return (len(self.items) == 0 and len(self.progressives) == 0 and len(self.rooms) == 0 and len(self.letters) == 0
89 and not self.cyans and len(self.or_logic) == 0 and self.complete_at is None)
90
91 def __eq__(self, other: "AccessRequirements"):
92 return (self.items == other.items and self.progressives == other.progressives and self.rooms == other.rooms and
93 self.letters == other.letters and self.cyans == other.cyans and self.or_logic == other.or_logic and
94 self.complete_at == other.complete_at and self.possibilities == other.possibilities)
95
96 def simplify(self):
97 resimplify = False
98
99 if len(self.or_logic) > 0:
100 old_or_logic = self.or_logic
101
102 def remove_redundant(sub_reqs: "AccessRequirements"):
103 new_reqs = sub_reqs.copy()
104 new_reqs.letters = {l: v for l, v in new_reqs.letters.items() if self.letters.get(l, 0) < v}
105 if new_reqs != sub_reqs:
106 return new_reqs
107 else:
108 return sub_reqs
109
110 self.or_logic = []
111 for disjunction in old_or_logic:
112 new_disjunction = []
113 for ssr in disjunction:
114 new_ssr = remove_redundant(ssr)
115 if not new_ssr.is_empty():
116 new_disjunction.append(new_ssr)
117 else:
118 new_disjunction.clear()
119 break
120 if len(new_disjunction) == 1:
121 self.merge(new_disjunction[0])
122 resimplify = True
123 elif len(new_disjunction) > 1:
124 if all(cjr == new_disjunction[0] for cjr in new_disjunction):
125 self.merge(new_disjunction[0])
126 resimplify = True
127 else:
128 self.or_logic.append(new_disjunction)
129
130 if resimplify:
131 self.simplify()
132
133 def get_referenced_rooms(self):
134 result = set(self.rooms)
135
136 for disjunction in self.or_logic:
137 for sub_req in disjunction:
138 result = result.union(sub_req.get_referenced_rooms())
139
140 for sub_req in self.possibilities:
141 result = result.union(sub_req.get_referenced_rooms())
142
143 return result
144
145 def remove_room(self, room: str):
146 if room in self.rooms:
147 self.rooms.remove(room)
148
149 for disjunction in self.or_logic:
150 for sub_req in disjunction:
151 sub_req.remove_room(room)
152
153 for sub_req in self.possibilities:
154 sub_req.remove_room(room)
155
156 def __repr__(self):
157 parts = []
158 if len(self.items) > 0:
159 parts.append(f"items={self.items}")
160 if len(self.progressives) > 0:
161 parts.append(f"progressives={self.progressives}")
162 if len(self.rooms) > 0:
163 parts.append(f"rooms={self.rooms}")
164 if len(self.letters) > 0:
165 parts.append(f"letters={self.letters}")
166 if self.cyans:
167 parts.append(f"cyans=True")
168 if len(self.or_logic) > 0:
169 parts.append(f"or_logic={self.or_logic}")
170 if self.complete_at is not None:
171 parts.append(f"complete_at={self.complete_at}")
172 if len(self.possibilities) > 0:
173 parts.append(f"possibilities={self.possibilities}")
174 return "AccessRequirements(" + ", ".join(parts) + ")"
175
176 def check_access(self, state: CollectionState, world: "Lingo2World") -> bool:
177 if not all(state.has(item, world.player) for item in self.items):
27 return False 178 return False
28 179
29 if reqs.cyans: 180 if not all(state.has(item, world.player, amount) for item, amount in self.progressives.items()):
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 181 return False
33 182
34 if len(reqs.or_logic) > 0: 183 if not all(state.can_reach_region(region_name, world.player) for region_name in self.rooms):
35 if not all(any(lingo2_can_satisfy_requirements(state, sub_reqs, [], world) for sub_reqs in subjunction)
36 for subjunction in reqs.or_logic):
37 return False 184 return False
38 185
39 if reqs.complete_at is not None: 186 for letter_key, letter_level in self.letters.items():
40 completed = 0 187 if not state.has(letter_key, world.player, letter_level):
41 checked = 0 188 return False
42 for possibility in reqs.possibilities: 189
43 checked += 1 190 if self.cyans:
44 if lingo2_can_satisfy_requirements(state, possibility, [], world): 191 if not any(state.has(letter, world.player, amount)
45 completed += 1 192 for letter, amount in world.player_logic.double_letter_amount.items()):
46 if completed >= reqs.complete_at: 193 return False
47 break 194
48 elif len(reqs.possibilities) - checked + completed < reqs.complete_at: 195 if len(self.or_logic) > 0:
49 # There aren't enough remaining possibilities for the check to pass. 196 if not all(any(sub_reqs.check_access(state, world) for sub_reqs in subjunction)
197 for subjunction in self.or_logic):
198 return False
199
200 if self.complete_at is not None:
201 completed = 0
202 checked = 0
203 for possibility in self.possibilities:
204 checked += 1
205 if possibility.check_access(state, world):
206 completed += 1
207 if completed >= self.complete_at:
208 break
209 elif len(self.possibilities) - checked + completed < self.complete_at:
210 # There aren't enough remaining possibilities for the check to pass.
211 return False
212 if completed < self.complete_at:
50 return False 213 return False
51 if completed < reqs.complete_at:
52 return False
53 214
54 return True 215 return True
55
56def make_location_lambda(reqs: AccessRequirements, world: "Lingo2World",
57 regions: dict[str, Region] | None) -> 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 if regions is not None:
61 required_regions = [regions[room_name] for room_name in reqs.rooms]
62 else:
63 required_regions = [world.multiworld.get_region(room_name, world.player) for room_name in reqs.rooms]
64 new_reqs = reqs.copy()
65 new_reqs.rooms.clear()
66 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 715178e..48ad78e 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py
@@ -18,6 +18,8 @@ class Lingo2StaticLogic:
18 door_id_by_ap_id: dict[int, int] 18 door_id_by_ap_id: dict[int, int]
19 port_id_by_ap_id: dict[int, int] 19 port_id_by_ap_id: dict[int, int]
20 20
21 map_id_by_name: dict[str, int]
22
21 def __init__(self): 23 def __init__(self):
22 self.item_id_to_name = {} 24 self.item_id_to_name = {}
23 self.location_id_to_name = {} 25 self.location_id_to_name = {}
@@ -73,12 +75,18 @@ class Lingo2StaticLogic:
73 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done" 75 self.item_id_to_name[self.objects.special_ids["A Job Well Done"]] = "A Job Well Done"
74 self.item_id_to_name[self.objects.special_ids["Numbers"]] = "Numbers" 76 self.item_id_to_name[self.objects.special_ids["Numbers"]] = "Numbers"
75 77
78 self.item_name_groups["Symbols"] = []
76 for symbol_name in SYMBOL_ITEMS.values(): 79 for symbol_name in SYMBOL_ITEMS.values():
77 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name 80 self.item_id_to_name[self.objects.special_ids[symbol_name]] = symbol_name
81 self.item_name_groups["Symbols"].append(symbol_name)
78 82
79 for trap_name in ANTI_COLLECTABLE_TRAPS: 83 for trap_name in ANTI_COLLECTABLE_TRAPS:
80 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name 84 self.item_id_to_name[self.objects.special_ids[trap_name]] = trap_name
81 85
86 for game_map in self.objects.maps:
87 if game_map.HasField("rte_room"):
88 self.item_id_to_name[game_map.rte_ap_id] = self.get_map_rte_item_name(game_map.id)
89
82 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()} 90 self.item_name_to_id = {name: ap_id for ap_id, name in self.item_id_to_name.items()}
83 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()} 91 self.location_name_to_id = {name: ap_id for ap_id, name in self.location_id_to_name.items()}
84 92
@@ -90,6 +98,8 @@ class Lingo2StaticLogic:
90 self.door_id_by_ap_id = {door.ap_id: door.id for door in self.objects.doors if door.HasField("ap_id")} 98 self.door_id_by_ap_id = {door.ap_id: door.id for door in self.objects.doors if door.HasField("ap_id")}
91 self.port_id_by_ap_id = {port.ap_id: port.id for port in self.objects.ports if port.HasField("ap_id")} 99 self.port_id_by_ap_id = {port.ap_id: port.id for port in self.objects.ports if port.HasField("ap_id")}
92 100
101 self.map_id_by_name = {game_map.name: game_map.id for game_map in self.objects.maps}
102
93 def get_door_item_name(self, door: data_pb2.Door) -> str: 103 def get_door_item_name(self, door: data_pb2.Door) -> str:
94 return f"{self.get_map_object_map_name(door)} - {door.name}" 104 return f"{self.get_map_object_map_name(door)} - {door.name}"
95 105
@@ -177,6 +187,10 @@ class Lingo2StaticLogic:
177 def get_room_object_map_id(self, obj) -> int: 187 def get_room_object_map_id(self, obj) -> int:
178 return self.objects.rooms[obj.room_id].map_id 188 return self.objects.rooms[obj.room_id].map_id
179 189
190 def get_map_rte_item_name(self, map_id: int) -> str:
191 game_map = self.objects.maps[map_id]
192 return f"Return to {game_map.display_name}"
193
180 def get_data_version(self) -> list[int]: 194 def get_data_version(self) -> list[int]:
181 version = self.objects.version 195 version = self.objects.version
182 return [version.major, version.minor, version.patch] 196 return [version.major, version.minor, version.patch]
diff --git a/apworld/tracker.py b/apworld/tracker.py index a84c3f8..3e1cafb 100644 --- a/apworld/tracker.py +++ b/apworld/tracker.py
@@ -44,6 +44,10 @@ class Tracker:
44 for k, t in Lingo2Options.type_hints.items()}) 44 for k, t in Lingo2Options.type_hints.items()})
45 45
46 self.world.generate_early() 46 self.world.generate_early()
47
48 self.world.player_logic.rte_mapping = [self.world.static_logic.map_id_by_name[map_name]
49 for map_name in slot_data.get("rte", [])]
50
47 self.world.create_regions() 51 self.world.create_regions()
48 52
49 if self.world.options.shuffle_worldports: 53 if self.world.options.shuffle_worldports: