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__.py7
-rw-r--r--apworld/client/assets/location.pngbin0 -> 311 bytes
-rw-r--r--apworld/client/assets/worldport.pngbin0 -> 219 bytes
-rw-r--r--apworld/client/client.gd48
-rw-r--r--apworld/client/gamedata.gd12
-rw-r--r--apworld/client/keyboard.gd36
-rw-r--r--apworld/client/manager.gd14
-rw-r--r--apworld/client/textclient.gd127
-rw-r--r--apworld/client/worldport.gd10
-rw-r--r--apworld/context.py452
-rw-r--r--apworld/locations.py2
-rw-r--r--apworld/logo.pngbin0 -> 9429 bytes
-rw-r--r--apworld/player_logic.py13
-rw-r--r--apworld/regions.py29
-rw-r--r--apworld/static_logic.py1
-rw-r--r--apworld/tracker.py65
16 files changed, 661 insertions, 155 deletions
diff --git a/apworld/__init__.py b/apworld/__init__.py index f99f5f5..1af31c0 100644 --- a/apworld/__init__.py +++ b/apworld/__init__.py
@@ -12,7 +12,7 @@ from .player_logic import Lingo2PlayerLogic
12from .regions import create_regions, shuffle_entrances, connect_ports_from_ut 12from .regions import create_regions, shuffle_entrances, connect_ports_from_ut
13from .static_logic import Lingo2StaticLogic 13from .static_logic import Lingo2StaticLogic
14from .version import APWORLD_VERSION 14from .version import APWORLD_VERSION
15from ..LauncherComponents import Component, Type, components, launch as launch_component 15from ..LauncherComponents import Component, Type, components, launch as launch_component, icon_paths
16 16
17 17
18class Lingo2WebWorld(WebWorld): 18class Lingo2WebWorld(WebWorld):
@@ -59,6 +59,8 @@ class Lingo2World(World):
59 item_name_groups = static_logic.item_name_groups 59 item_name_groups = static_logic.item_name_groups
60 location_name_groups = static_logic.location_name_groups 60 location_name_groups = static_logic.location_name_groups
61 61
62 for_tracker: ClassVar[bool] = False
63
62 player_logic: Lingo2PlayerLogic 64 player_logic: Lingo2PlayerLogic
63 65
64 port_pairings: dict[int, int] 66 port_pairings: dict[int, int]
@@ -161,6 +163,7 @@ def launch_client(*args):
161 launch_component(client_main, name="Lingo2Client", args=args) 163 launch_component(client_main, name="Lingo2Client", args=args)
162 164
163 165
166icon_paths["lingo2_ico"] = f"ap:{__name__}/logo.png"
164component = Component("Lingo 2 Client", component_type=Type.CLIENT, func=launch_client, 167component = Component("Lingo 2 Client", component_type=Type.CLIENT, func=launch_client,
165 description="Open Lingo 2.", supports_uri=True, game_name="Lingo 2") 168 description="Open Lingo 2.", supports_uri=True, game_name="Lingo 2", icon="lingo2_ico")
166components.append(component) 169components.append(component)
diff --git a/apworld/client/assets/location.png b/apworld/client/assets/location.png new file mode 100644 index 0000000..5304deb --- /dev/null +++ b/apworld/client/assets/location.png
Binary files differ
diff --git a/apworld/client/assets/worldport.png b/apworld/client/assets/worldport.png new file mode 100644 index 0000000..19dfdc3 --- /dev/null +++ b/apworld/client/assets/worldport.png
Binary files differ
diff --git a/apworld/client/client.gd b/apworld/client/client.gd index 286ad4b..a23e85a 100644 --- a/apworld/client/client.gd +++ b/apworld/client/client.gd
@@ -18,10 +18,12 @@ var _seed = ""
18var _team = 0 18var _team = 0
19var _slot = 0 19var _slot = 0
20var _checked_locations = [] 20var _checked_locations = []
21var _checked_worldports = []
21var _received_indexes = [] 22var _received_indexes = []
22var _received_items = {} 23var _received_items = {}
23var _slot_data = {} 24var _slot_data = {}
24var _accessible_locations = [] 25var _accessible_locations = []
26var _accessible_worldports = []
25 27
26signal could_not_connect 28signal could_not_connect
27signal connect_status 29signal connect_status
@@ -33,6 +35,8 @@ signal item_sent_notification(message)
33signal hint_received(message) 35signal hint_received(message)
34signal accessible_locations_updated 36signal accessible_locations_updated
35signal checked_locations_updated 37signal checked_locations_updated
38signal checked_worldports_updated
39signal keyboard_update_received
36 40
37 41
38func _init(): 42func _init():
@@ -54,7 +58,9 @@ func _reset_state():
54 _should_process = false 58 _should_process = false
55 _received_items = {} 59 _received_items = {}
56 _received_indexes = [] 60 _received_indexes = []
61 _checked_worldports = []
57 _accessible_locations = [] 62 _accessible_locations = []
63 _accessible_worldports = []
58 64
59 65
60func disconnect_from_ap(): 66func disconnect_from_ap():
@@ -116,6 +122,14 @@ func _on_web_socket_server_message_received(_peer_id: int, packet: String) -> vo
116 122
117 checked_locations_updated.emit() 123 checked_locations_updated.emit()
118 124
125 elif cmd == "UpdateWorldports":
126 for port_id in message["worldports"]:
127 var lint = int(port_id)
128 if not _checked_worldports.has(lint):
129 _checked_worldports.append(lint)
130
131 checked_worldports_updated.emit()
132
119 elif cmd == "ItemReceived": 133 elif cmd == "ItemReceived":
120 for item in message["items"]: 134 for item in message["items"]:
121 var index = int(item["index"]) 135 var index = int(item["index"])
@@ -151,12 +165,24 @@ func _on_web_socket_server_message_received(_peer_id: int, packet: String) -> vo
151 165
152 elif cmd == "AccessibleLocations": 166 elif cmd == "AccessibleLocations":
153 _accessible_locations.clear() 167 _accessible_locations.clear()
168 _accessible_worldports.clear()
154 169
155 for loc in message["locations"]: 170 for loc in message["locations"]:
156 _accessible_locations.append(int(loc)) 171 _accessible_locations.append(int(loc))
157 172
173 if "worldports" in message:
174 for port_id in message["worldports"]:
175 _accessible_worldports.append(int(port_id))
176
158 accessible_locations_updated.emit() 177 accessible_locations_updated.emit()
159 178
179 elif cmd == "UpdateKeyboard":
180 var updates = {}
181 for k in message["updates"]:
182 updates[k] = int(message["updates"][k])
183
184 keyboard_update_received.emit(updates)
185
160 186
161func connectToServer(server, un, pw): 187func connectToServer(server, un, pw):
162 sendMessage([{"cmd": "Connect", "server": server, "player": un, "password": pw}]) 188 sendMessage([{"cmd": "Connect", "server": server, "player": un, "password": pw}])
@@ -202,19 +228,6 @@ func sendLocations(loc_ids):
202 sendMessage([{"cmd": "LocationChecks", "locations": loc_ids}]) 228 sendMessage([{"cmd": "LocationChecks", "locations": loc_ids}])
203 229
204 230
205func setValue(key, value, operation = "replace"):
206 sendMessage(
207 [
208 {
209 "cmd": "Set",
210 "key": "Lingo2_%d_%s" % [_slot, key],
211 "want_reply": false,
212 "operations": [{"operation": operation, "value": value}]
213 }
214 ]
215 )
216
217
218func say(textdata): 231func say(textdata):
219 sendMessage([{"cmd": "Say", "text": textdata}]) 232 sendMessage([{"cmd": "Say", "text": textdata}])
220 233
@@ -227,6 +240,15 @@ func scoutLocations(loc_ids):
227 sendMessage([{"cmd": "LocationScouts", "locations": loc_ids}]) 240 sendMessage([{"cmd": "LocationScouts", "locations": loc_ids}])
228 241
229 242
243func updateKeyboard(updates):
244 sendMessage([{"cmd": "UpdateKeyboard", "keyboard": updates}])
245
246
247func checkWorldport(port_id):
248 if not _checked_worldports.has(port_id):
249 sendMessage([{"cmd": "CheckWorldport", "port_id": port_id}])
250
251
230func sendQuit(): 252func sendQuit():
231 sendMessage([{"cmd": "Quit"}]) 253 sendMessage([{"cmd": "Quit"}])
232 254
diff --git a/apworld/client/gamedata.gd b/apworld/client/gamedata.gd index 39e0583..1424721 100644 --- a/apworld/client/gamedata.gd +++ b/apworld/client/gamedata.gd
@@ -161,11 +161,23 @@ func get_door_ap_id(door_id):
161 return null 161 return null
162 162
163 163
164func get_door_map_name(door_id):
165 var door = objects.get_doors()[door_id]
166 var room = objects.get_rooms()[door.get_room_id()]
167 var map = objects.get_maps()[room.get_map_id()]
168 return map.get_name()
169
170
164func get_door_receivers(door_id): 171func get_door_receivers(door_id):
165 var door = objects.get_doors()[door_id] 172 var door = objects.get_doors()[door_id]
166 return door.get_receivers() 173 return door.get_receivers()
167 174
168 175
176func get_worldport_display_name(port_id):
177 var port = objects.get_ports()[port_id]
178 return "%s - %s" % [_get_room_object_map_name(port), port.get_display_name()]
179
180
169func _get_map_object_map_name(obj): 181func _get_map_object_map_name(obj):
170 return objects.get_maps()[obj.get_map_id()].get_display_name() 182 return objects.get_maps()[obj.get_map_id()].get_display_name()
171 183
diff --git a/apworld/client/keyboard.gd b/apworld/client/keyboard.gd index 450566d..a59c4d0 100644 --- a/apworld/client/keyboard.gd +++ b/apworld/client/keyboard.gd
@@ -48,6 +48,9 @@ func load_seed():
48 if localdata.size() > 2: 48 if localdata.size() > 2:
49 keyholder_state = localdata[2] 49 keyholder_state = localdata[2]
50 50
51 if not letters_saved.is_empty():
52 ap.client.updateKeyboard(letters_saved)
53
51 for k in kALL_LETTERS: 54 for k in kALL_LETTERS:
52 var level = 0 55 var level = 0
53 56
@@ -105,10 +108,20 @@ func update_unlocks():
105 108
106 109
107func collect_local_letter(key, level): 110func collect_local_letter(key, level):
108 if level < 0 or level > 2 or level < letters_saved.get(key, 0): 111 var ap = global.get_node("Archipelago")
112 var true_level = 0
113
114 if ap.get_letter_behavior(key, false) == ap.kLETTER_BEHAVIOR_VANILLA:
115 true_level += 1
116 if level == 2 and ap.get_letter_behavior(key, true) == ap.kLETTER_BEHAVIOR_VANILLA:
117 true_level += 1
118
119 if true_level < letters_saved.get(key, 0):
109 return 120 return
110 121
111 letters_saved[key] = level 122 letters_saved[key] = true_level
123
124 ap.client.updateKeyboard({key: true_level})
112 125
113 if letters_blocked.has(key): 126 if letters_blocked.has(key):
114 letters_blocked.erase(key) 127 letters_blocked.erase(key)
@@ -197,3 +210,22 @@ func reset_keyholders():
197 save() 210 save()
198 211
199 return cleared_anything 212 return cleared_anything
213
214
215func remote_keyboard_updated(updates):
216 var reverse = {}
217 var should_update = false
218
219 for k in updates:
220 if not letters_saved.has(k) or updates[k] > letters_saved[k]:
221 letters_saved[k] = updates[k]
222 should_update = true
223 elif updates[k] < letters_saved[k]:
224 reverse[k] = letters_saved[k]
225
226 if should_update:
227 update_unlocks()
228
229 if not reverse.is_empty():
230 var ap = global.get_node("Archipelago")
231 ap.client.updateKeyboard(reverse)
diff --git a/apworld/client/manager.gd b/apworld/client/manager.gd index e7765dd..5b731d2 100644 --- a/apworld/client/manager.gd +++ b/apworld/client/manager.gd
@@ -109,6 +109,7 @@ func _ready():
109 client.hint_received.connect(_process_hint_received) 109 client.hint_received.connect(_process_hint_received)
110 client.accessible_locations_updated.connect(_on_accessible_locations_updated) 110 client.accessible_locations_updated.connect(_on_accessible_locations_updated)
111 client.checked_locations_updated.connect(_on_checked_locations_updated) 111 client.checked_locations_updated.connect(_on_checked_locations_updated)
112 client.checked_worldports_updated.connect(_on_checked_worldports_updated)
112 113
113 client.could_not_connect.connect(_client_could_not_connect) 114 client.could_not_connect.connect(_client_could_not_connect)
114 client.connect_status.connect(_client_connect_status) 115 client.connect_status.connect(_client_connect_status)
@@ -118,6 +119,7 @@ func _ready():
118 119
119 keyboard = SCRIPT_keyboard.new() 120 keyboard = SCRIPT_keyboard.new()
120 add_child(keyboard) 121 add_child(keyboard)
122 client.keyboard_update_received.connect(keyboard.remote_keyboard_updated)
121 123
122 124
123func saveSettings(): 125func saveSettings():
@@ -194,6 +196,7 @@ func _process_item(item, amount):
194 if gamedata.get_door_map_name(lock[0]) != global.map: 196 if gamedata.get_door_map_name(lock[0]) != global.map:
195 continue 197 continue
196 198
199 # TODO: fix doors opening from door groups
197 var receivers = gamedata.get_door_receivers(lock[0]) 200 var receivers = gamedata.get_door_receivers(lock[0])
198 var scene = get_tree().get_root().get_node_or_null("scene") 201 var scene = get_tree().get_root().get_node_or_null("scene")
199 if scene != null: 202 if scene != null:
@@ -326,6 +329,13 @@ func _on_checked_locations_updated():
326 textclient_node.update_locations() 329 textclient_node.update_locations()
327 330
328 331
332func _on_checked_worldports_updated():
333 var textclient_node = global.get_node("Textclient")
334 if textclient_node != null:
335 textclient_node.update_locations()
336 textclient_node.update_worldports()
337
338
329func _client_could_not_connect(message): 339func _client_could_not_connect(message):
330 could_not_connect.emit(message) 340 could_not_connect.emit(message)
331 341
@@ -440,6 +450,10 @@ func _client_connected(slot_data):
440 450
441 _inverse_item_locks[lock[0]].append([door_id, lock[1]]) 451 _inverse_item_locks[lock[0]].append([door_id, lock[1]])
442 452
453 if shuffle_worldports:
454 var textclient = global.get_node("Textclient")
455 textclient.setup_worldports()
456
443 ap_connected.emit() 457 ap_connected.emit()
444 458
445 459
diff --git a/apworld/client/textclient.gd b/apworld/client/textclient.gd index 1b36c29..0c4e675 100644 --- a/apworld/client/textclient.gd +++ b/apworld/client/textclient.gd
@@ -8,6 +8,13 @@ var tracker_label
8var is_open = false 8var is_open = false
9 9
10var locations_overlay 10var locations_overlay
11var location_texture
12var worldport_texture
13
14var worldports_tab
15var worldports_tree
16var port_tree_item_by_map = {}
17var port_tree_item_by_map_port = {}
11 18
12 19
13func _ready(): 20func _ready():
@@ -20,9 +27,10 @@ func _ready():
20 locations_overlay.offset_bottom = 720 27 locations_overlay.offset_bottom = 720
21 locations_overlay.offset_left = 20 28 locations_overlay.offset_left = 20
22 locations_overlay.anchor_right = 1.0 29 locations_overlay.anchor_right = 1.0
23 locations_overlay.offset_right = -20 30 locations_overlay.offset_right = -10
24 locations_overlay.scroll_active = false 31 locations_overlay.scroll_active = false
25 locations_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE 32 locations_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
33 locations_overlay.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
26 add_child(locations_overlay) 34 add_child(locations_overlay)
27 update_locations_visibility() 35 update_locations_visibility()
28 36
@@ -83,6 +91,31 @@ func _ready():
83 tracker_label = RichTextLabel.new() 91 tracker_label = RichTextLabel.new()
84 tracker_margins.add_child(tracker_label) 92 tracker_margins.add_child(tracker_label)
85 93
94 worldports_tab = MarginContainer.new()
95 worldports_tab.name = "Worldports"
96 worldports_tab.add_theme_constant_override("margin_top", 60)
97 worldports_tab.add_theme_constant_override("margin_left", 60)
98 worldports_tab.add_theme_constant_override("margin_right", 60)
99 worldports_tab.add_theme_constant_override("margin_bottom", 60)
100 tabs.add_child(worldports_tab)
101 tabs.set_tab_hidden(2, true)
102
103 worldports_tree = Tree.new()
104 worldports_tree.columns = 2
105 worldports_tree.hide_root = true
106 worldports_tree.theme = preload("res://assets/themes/baseUI.tres")
107 worldports_tree.add_theme_font_size_override("font_size", 24)
108 worldports_tab.add_child(worldports_tree)
109
110 var runtime = global.get_node("Runtime")
111 var location_image = Image.new()
112 location_image.load_png_from_buffer(runtime.read_path("assets/location.png"))
113 location_texture = ImageTexture.create_from_image(location_image)
114
115 var worldport_image = Image.new()
116 worldport_image.load_png_from_buffer(runtime.read_path("assets/worldport.png"))
117 worldport_texture = ImageTexture.create_from_image(worldport_image)
118
86 119
87func _input(event): 120func _input(event):
88 if global.loaded and event is InputEventKey and event.pressed: 121 if global.loaded and event is InputEventKey and event.pressed:
@@ -144,11 +177,22 @@ func update_locations():
144 locations_overlay.push_outline_color(Color(0, 0, 0, 1)) 177 locations_overlay.push_outline_color(Color(0, 0, 0, 1))
145 locations_overlay.push_outline_size(2) 178 locations_overlay.push_outline_size(2)
146 179
180 const kLocation = 0
181 const kWorldport = 1
182
147 var location_names = [] 183 var location_names = []
184 var type_by_name = {}
148 for location_id in ap.client._accessible_locations: 185 for location_id in ap.client._accessible_locations:
149 if not ap.client._checked_locations.has(location_id): 186 if not ap.client._checked_locations.has(location_id):
150 var location_name = gamedata.location_name_by_id.get(location_id, "(Unknown)") 187 var location_name = gamedata.location_name_by_id.get(location_id, "(Unknown)")
151 location_names.append(location_name) 188 location_names.append(location_name)
189 type_by_name[location_name] = kLocation
190
191 for port_id in ap.client._accessible_worldports:
192 if not ap.client._checked_worldports.has(port_id):
193 var port_name = gamedata.get_worldport_display_name(port_id)
194 location_names.append(port_name)
195 type_by_name[port_name] = kWorldport
152 196
153 location_names.sort() 197 location_names.sort()
154 198
@@ -156,7 +200,14 @@ func update_locations():
156 for location_name in location_names: 200 for location_name in location_names:
157 tracker_label.append_text("[p]%s[/p]" % location_name) 201 tracker_label.append_text("[p]%s[/p]" % location_name)
158 if count < 18: 202 if count < 18:
159 locations_overlay.append_text("[p align=right]%s[/p]" % location_name) 203 locations_overlay.push_paragraph(HORIZONTAL_ALIGNMENT_RIGHT)
204 locations_overlay.append_text(location_name)
205 locations_overlay.append_text(" ")
206 if type_by_name[location_name] == kLocation:
207 locations_overlay.add_image(location_texture)
208 elif type_by_name[location_name] == kWorldport:
209 locations_overlay.add_image(worldport_texture)
210 locations_overlay.pop()
160 count += 1 211 count += 1
161 212
162 if count > 18: 213 if count > 18:
@@ -168,5 +219,77 @@ func update_locations_visibility():
168 locations_overlay.visible = ap.show_locations 219 locations_overlay.visible = ap.show_locations
169 220
170 221
222func setup_worldports():
223 tabs.set_tab_hidden(2, false)
224
225 var root_ti = worldports_tree.create_item(null)
226
227 var ports_by_map_id = {}
228 var display_names_by_map_id = {}
229 var display_names_by_port_id = {}
230
231 var ap = global.get_node("Archipelago")
232 var gamedata = global.get_node("Gamedata")
233 for fpid in ap.port_pairings:
234 var port = gamedata.objects.get_ports()[fpid]
235 var room = gamedata.objects.get_rooms()[port.get_room_id()]
236
237 if not ports_by_map_id.has(room.get_map_id()):
238 ports_by_map_id[room.get_map_id()] = []
239
240 var map = gamedata.objects.get_maps()[room.get_map_id()]
241 display_names_by_map_id[map.get_id()] = map.get_display_name()
242
243 ports_by_map_id[room.get_map_id()].append(fpid)
244 display_names_by_port_id[fpid] = port.get_display_name()
245
246 var sorted_map_ids = ports_by_map_id.keys().duplicate()
247 sorted_map_ids.sort_custom(
248 func(a, b): return display_names_by_map_id[a] < display_names_by_map_id[b]
249 )
250
251 for map_id in sorted_map_ids:
252 var map_ti = root_ti.create_child()
253 map_ti.set_text(0, display_names_by_map_id[map_id])
254 map_ti.visible = false
255 map_ti.collapsed = true
256 port_tree_item_by_map[map_id] = map_ti
257 port_tree_item_by_map_port[map_id] = {}
258
259 var port_ids = ports_by_map_id[map_id]
260 port_ids.sort_custom(
261 func(a, b): return display_names_by_port_id[a] < display_names_by_port_id[b]
262 )
263
264 for port_id in port_ids:
265 var port_ti = map_ti.create_child()
266 port_ti.set_text(0, display_names_by_port_id[port_id])
267 port_ti.set_text(1, gamedata.get_worldport_display_name(ap.port_pairings[port_id]))
268 port_ti.visible = false
269 port_tree_item_by_map_port[map_id][port_id] = port_ti
270
271 update_worldports()
272
273
274func update_worldports():
275 var ap = global.get_node("Archipelago")
276
277 for map_id in port_tree_item_by_map_port.keys():
278 var map_visible = false
279
280 for port_id in port_tree_item_by_map_port[map_id].keys():
281 var ti = port_tree_item_by_map_port[map_id][port_id]
282 ti.visible = ap.client._checked_worldports.has(port_id)
283
284 if ti.visible:
285 map_visible = true
286
287 port_tree_item_by_map[map_id].visible = map_visible
288
289
171func reset(): 290func reset():
172 locations_overlay.clear() 291 locations_overlay.clear()
292 tabs.set_tab_hidden(2, true)
293 port_tree_item_by_map.clear()
294 port_tree_item_by_map_port.clear()
295 worldports_tree.clear()
diff --git a/apworld/client/worldport.gd b/apworld/client/worldport.gd index cdca248..ed9891e 100644 --- a/apworld/client/worldport.gd +++ b/apworld/client/worldport.gd
@@ -3,6 +3,8 @@ extends "res://scripts/nodes/worldport.gd"
3var absolute_rotation = false 3var absolute_rotation = false
4var target_rotation = 0 4var target_rotation = 0
5 5
6var port_id = null
7
6 8
7func _ready(): 9func _ready():
8 var node_path = String( 10 var node_path = String(
@@ -13,7 +15,7 @@ func _ready():
13 15
14 if ap.shuffle_worldports: 16 if ap.shuffle_worldports:
15 var gamedata = global.get_node("Gamedata") 17 var gamedata = global.get_node("Gamedata")
16 var port_id = gamedata.get_port_for_map_node_path(global.map, node_path) 18 port_id = gamedata.get_port_for_map_node_path(global.map, node_path)
17 if port_id != null: 19 if port_id != null:
18 if port_id in ap.port_pairings: 20 if port_id in ap.port_pairings:
19 var target_port = gamedata.objects.get_ports()[ap.port_pairings[port_id]] 21 var target_port = gamedata.objects.get_ports()[ap.port_pairings[port_id]]
@@ -29,6 +31,8 @@ func _ready():
29 sets_entry_point = true 31 sets_entry_point = true
30 invisible = false 32 invisible = false
31 fades = true 33 fades = true
34 else:
35 port_id = null
32 36
33 if global.map == "icarus" and exit == "daedalus": 37 if global.map == "icarus" and exit == "daedalus":
34 if not ap.daedalus_roof_access: 38 if not ap.daedalus_roof_access:
@@ -39,6 +43,10 @@ func _ready():
39 43
40func bodyEntered(body): 44func bodyEntered(body):
41 if body.is_in_group("player"): 45 if body.is_in_group("player"):
46 if port_id != null:
47 var ap = global.get_node("Archipelago")
48 ap.client.checkWorldport(port_id)
49
42 if absolute_rotation: 50 if absolute_rotation:
43 entry_rotate.y = target_rotation - body.rotation_degrees.y 51 entry_rotate.y = target_rotation - body.rotation_degrees.y
44 52
diff --git a/apworld/context.py b/apworld/context.py index 0a058e5..4b78517 100644 --- a/apworld/context.py +++ b/apworld/context.py
@@ -15,35 +15,99 @@ from Utils import async_start
15from . import Lingo2World 15from . import Lingo2World
16from .tracker import Tracker 16from .tracker import Tracker
17 17
18PORT = 43182 18ALL_LETTERS = "abcdefghijklmnopqrstuvwxyz"
19MESSAGE_MAX_SIZE = 16*1024*1024 19MESSAGE_MAX_SIZE = 16*1024*1024
20PORT = 43182
21
22KEY_STORAGE_MAPPING = {
23 "a": (1, 0), "b": (1, 1), "c": (1, 2), "d": (1, 3), "e": (1, 4), "f": (1, 5), "g": (1, 6), "h": (1, 7), "i": (1, 8),
24 "j": (1, 9), "k": (1, 10), "l": (1, 11), "m": (1, 12), "n": (2, 0), "o": (2, 1), "p": (2, 2), "q": (2, 3),
25 "r": (2, 4), "s": (2, 5), "t": (2, 6), "u": (2, 7), "v": (2, 8), "w": (2, 9), "x": (2, 10), "y": (2, 11),
26 "z": (2, 12),
27}
28
29REVERSE_KEY_STORAGE_MAPPING = {t: k for k, t in KEY_STORAGE_MAPPING.items()}
30
31
32class Lingo2Manager:
33 game_ctx: "Lingo2GameContext"
34 client_ctx: "Lingo2ClientContext"
35 tracker: Tracker
36
37 keyboard: dict[str, int]
38 worldports: set[int]
39
40 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"):
41 self.game_ctx = game_ctx
42 self.game_ctx.manager = self
43 self.client_ctx = client_ctx
44 self.client_ctx.manager = self
45 self.tracker = Tracker(self)
46 self.keyboard = {}
47 self.worldports = set()
48
49 self.reset()
50
51 def reset(self):
52 for k in ALL_LETTERS:
53 self.keyboard[k] = 0
54
55 self.worldports = set()
56
57 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]:
58 ret: dict[str, int] = {}
59
60 for k, v in new_keyboard.items():
61 if v > self.keyboard.get(k, 0):
62 self.keyboard[k] = v
63 ret[k] = v
64
65 if len(ret) > 0:
66 self.tracker.refresh_state()
67 self.game_ctx.send_accessible_locations()
68
69 return ret
70
71 def update_worldports(self, new_worldports: set[int]) -> set[int]:
72 ret = new_worldports.difference(self.worldports)
73 self.worldports.update(new_worldports)
74
75 if len(ret) > 0:
76 self.tracker.refresh_state()
77 self.game_ctx.send_accessible_locations()
78
79 return ret
20 80
21 81
22class Lingo2GameContext: 82class Lingo2GameContext:
23 server: Endpoint | None 83 server: Endpoint | None
24 client: "Lingo2ClientContext" 84 manager: Lingo2Manager
25 tracker: Tracker
26 85
27 def __init__(self): 86 def __init__(self):
28 self.server = None 87 self.server = None
29 self.tracker = Tracker()
30 88
31 def send_connected(self): 89 def send_connected(self):
90 if self.server is None:
91 return
92
32 msg = { 93 msg = {
33 "cmd": "Connected", 94 "cmd": "Connected",
34 "user": self.client.username, 95 "user": self.manager.client_ctx.username,
35 "seed_name": self.client.seed_name, 96 "seed_name": self.manager.client_ctx.seed_name,
36 "version": self.client.server_version, 97 "version": self.manager.client_ctx.server_version,
37 "generator_version": self.client.generator_version, 98 "generator_version": self.manager.client_ctx.generator_version,
38 "team": self.client.team, 99 "team": self.manager.client_ctx.team,
39 "slot": self.client.slot, 100 "slot": self.manager.client_ctx.slot,
40 "checked_locations": self.client.checked_locations, 101 "checked_locations": self.manager.client_ctx.checked_locations,
41 "slot_data": self.client.slot_data, 102 "slot_data": self.manager.client_ctx.slot_data,
42 } 103 }
43 104
44 async_start(self.send_msgs([msg]), name="game Connected") 105 async_start(self.send_msgs([msg]), name="game Connected")
45 106
46 def send_item_sent_notification(self, item_name, receiver_name, item_flags): 107 def send_item_sent_notification(self, item_name, receiver_name, item_flags):
108 if self.server is None:
109 return
110
47 msg = { 111 msg = {
48 "cmd": "ItemSentNotif", 112 "cmd": "ItemSentNotif",
49 "item_name": item_name, 113 "item_name": item_name,
@@ -54,6 +118,9 @@ class Lingo2GameContext:
54 async_start(self.send_msgs([msg]), name="item sent notif") 118 async_start(self.send_msgs([msg]), name="item sent notif")
55 119
56 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self): 120 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self):
121 if self.server is None:
122 return
123
57 msg = { 124 msg = {
58 "cmd": "HintReceived", 125 "cmd": "HintReceived",
59 "item_name": item_name, 126 "item_name": item_name,
@@ -66,6 +133,9 @@ class Lingo2GameContext:
66 async_start(self.send_msgs([msg]), name="hint received notif") 133 async_start(self.send_msgs([msg]), name="hint received notif")
67 134
68 def send_item_received(self, items): 135 def send_item_received(self, items):
136 if self.server is None:
137 return
138
69 msg = { 139 msg = {
70 "cmd": "ItemReceived", 140 "cmd": "ItemReceived",
71 "items": items, 141 "items": items,
@@ -74,6 +144,9 @@ class Lingo2GameContext:
74 async_start(self.send_msgs([msg]), name="item received") 144 async_start(self.send_msgs([msg]), name="item received")
75 145
76 def send_location_info(self, locations): 146 def send_location_info(self, locations):
147 if self.server is None:
148 return
149
77 msg = { 150 msg = {
78 "cmd": "LocationInfo", 151 "cmd": "LocationInfo",
79 "locations": locations, 152 "locations": locations,
@@ -82,6 +155,9 @@ class Lingo2GameContext:
82 async_start(self.send_msgs([msg]), name="location info") 155 async_start(self.send_msgs([msg]), name="location info")
83 156
84 def send_text_message(self, parts): 157 def send_text_message(self, parts):
158 if self.server is None:
159 return
160
85 msg = { 161 msg = {
86 "cmd": "TextMessage", 162 "cmd": "TextMessage",
87 "data": parts, 163 "data": parts,
@@ -90,14 +166,23 @@ class Lingo2GameContext:
90 async_start(self.send_msgs([msg]), name="notif") 166 async_start(self.send_msgs([msg]), name="notif")
91 167
92 def send_accessible_locations(self): 168 def send_accessible_locations(self):
169 if self.server is None:
170 return
171
93 msg = { 172 msg = {
94 "cmd": "AccessibleLocations", 173 "cmd": "AccessibleLocations",
95 "locations": list(self.tracker.accessible_locations), 174 "locations": list(self.manager.tracker.accessible_locations),
96 } 175 }
97 176
177 if len(self.manager.tracker.accessible_worldports) > 0:
178 msg["worldports"] = list(self.manager.tracker.accessible_worldports)
179
98 async_start(self.send_msgs([msg]), name="accessible locations") 180 async_start(self.send_msgs([msg]), name="accessible locations")
99 181
100 def send_update_locations(self, locations): 182 def send_update_locations(self, locations):
183 if self.server is None:
184 return
185
101 msg = { 186 msg = {
102 "cmd": "UpdateLocations", 187 "cmd": "UpdateLocations",
103 "locations": locations, 188 "locations": locations,
@@ -105,6 +190,28 @@ class Lingo2GameContext:
105 190
106 async_start(self.send_msgs([msg]), name="update locations") 191 async_start(self.send_msgs([msg]), name="update locations")
107 192
193 def send_update_keyboard(self, updates):
194 if self.server is None:
195 return
196
197 msg = {
198 "cmd": "UpdateKeyboard",
199 "updates": updates,
200 }
201
202 async_start(self.send_msgs([msg]), name="update keyboard")
203
204 def send_update_worldports(self, worldports):
205 if self.server is None:
206 return
207
208 msg = {
209 "cmd": "UpdateWorldports",
210 "worldports": worldports,
211 }
212
213 async_start(self.send_msgs([msg]), name="update worldports")
214
108 async def send_msgs(self, msgs: list[Any]) -> None: 215 async def send_msgs(self, msgs: list[Any]) -> None:
109 """ `msgs` JSON serializable """ 216 """ `msgs` JSON serializable """
110 if not self.server or not self.server.socket.open or self.server.socket.closed: 217 if not self.server or not self.server.socket.open or self.server.socket.closed:
@@ -113,7 +220,7 @@ class Lingo2GameContext:
113 220
114 221
115class Lingo2ClientContext(CommonContext): 222class Lingo2ClientContext(CommonContext):
116 game_ctx: Lingo2GameContext 223 manager: Lingo2Manager
117 224
118 game = "Lingo 2" 225 game = "Lingo 2"
119 items_handling = 0b111 226 items_handling = 0b111
@@ -138,118 +245,227 @@ class Lingo2ClientContext(CommonContext):
138 elif cmd == "Connected": 245 elif cmd == "Connected":
139 self.slot_data = args.get("slot_data", None) 246 self.slot_data = args.get("slot_data", None)
140 247
141 if self.game_ctx.server is not None: 248 self.manager.reset()
142 self.game_ctx.send_connected() 249
143 250 self.manager.game_ctx.send_connected()
144 self.game_ctx.tracker.setup_slot(self.slot_data) 251
252 self.manager.tracker.setup_slot(self.slot_data)
253 self.manager.tracker.set_checked_locations(self.checked_locations)
254 self.manager.game_ctx.send_accessible_locations()
255
256 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"))
257 msg_batch = [{
258 "cmd": "Set",
259 "key": self.get_datastorage_key("keyboard1"),
260 "default": 0,
261 "want_reply": True,
262 "operations": [{"operation": "default", "value": 0}]
263 }, {
264 "cmd": "Set",
265 "key": self.get_datastorage_key("keyboard2"),
266 "default": 0,
267 "want_reply": True,
268 "operations": [{"operation": "default", "value": 0}]
269 }]
270
271 if self.slot_data["shuffle_worldports"]:
272 self.set_notify(self.get_datastorage_key("worldports"))
273 msg_batch.append({
274 "cmd": "Set",
275 "key": self.get_datastorage_key("worldports"),
276 "default": [],
277 "want_reply": True,
278 "operations": [{"operation": "default", "value": []}]
279 })
280
281 async_start(self.send_msgs(msg_batch), name="default keys")
145 elif cmd == "RoomUpdate": 282 elif cmd == "RoomUpdate":
146 if self.game_ctx.server is not None: 283 if "checked_locations" in args:
147 self.game_ctx.send_update_locations(args["checked_locations"]) 284 self.manager.tracker.set_checked_locations(self.checked_locations)
285 self.manager.game_ctx.send_update_locations(args["checked_locations"])
148 elif cmd == "ReceivedItems": 286 elif cmd == "ReceivedItems":
149 self.game_ctx.tracker.set_collected_items(self.items_received) 287 self.manager.tracker.set_collected_items(self.items_received)
150 288
151 if self.game_ctx.server is not None: 289 cur_index = 0
152 cur_index = 0 290 items = []
153 items = []
154 291
155 for item in args["items"]: 292 for item in args["items"]:
156 index = cur_index + args["index"] 293 index = cur_index + args["index"]
157 cur_index += 1 294 cur_index += 1
158 295
159 item_msg = { 296 item_msg = {
160 "id": item.item, 297 "id": item.item,
161 "index": index, 298 "index": index,
162 "flags": item.flags, 299 "flags": item.flags,
163 "text": self.item_names.lookup_in_slot(item.item, self.slot), 300 "text": self.item_names.lookup_in_slot(item.item, self.slot),
164 } 301 }
165 302
166 if item.player != self.slot: 303 if item.player != self.slot:
167 item_msg["sender"] = self.player_names.get(item.player) 304 item_msg["sender"] = self.player_names.get(item.player)
168 305
169 items.append(item_msg) 306 items.append(item_msg)
170 307
171 self.game_ctx.send_item_received(items) 308 self.manager.game_ctx.send_item_received(items)
172 309
173 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]): 310 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
174 self.game_ctx.send_accessible_locations() 311 self.manager.game_ctx.send_accessible_locations()
175 elif cmd == "PrintJSON": 312 elif cmd == "PrintJSON":
176 if self.game_ctx.server is not None: 313 if "receiving" in args and "item" in args and args["item"].player == self.slot:
177 if "receiving" in args and "item" in args and args["item"].player == self.slot: 314 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"])
178 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"]) 315 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player)
179 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player) 316 receiver_name = self.player_names.get(args["receiving"])
180 receiver_name = self.player_names.get(args["receiving"]) 317
181 318 if args["type"] == "Hint" and not args.get("found", False):
182 if args["type"] == "Hint" and not args.get("found", False): 319 self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags,
183 self.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags, 320 int(args["receiving"]) == self.slot)
184 int(args["receiving"]) == self.slot) 321 elif args["receiving"] != self.slot:
185 elif args["receiving"] != self.slot: 322 self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags)
186 self.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags) 323
187 324 parts = []
188 parts = [] 325 for message_part in args["data"]:
189 for message_part in args["data"]: 326 if "type" not in message_part and "text" in message_part:
190 if "type" not in message_part and "text" in message_part: 327 parts.append({"type": "text", "text": message_part["text"]})
191 parts.append({"type": "text", "text": message_part["text"]}) 328 elif message_part["type"] == "player_id":
192 elif message_part["type"] == "player_id": 329 parts.append({
193 parts.append({ 330 "type": "player",
194 "type": "player", 331 "text": self.player_names.get(int(message_part["text"])),
195 "text": self.player_names.get(int(message_part["text"])), 332 "self": int(int(message_part["text"]) == self.slot),
196 "self": int(int(message_part["text"]) == self.slot),
197 })
198 elif message_part["type"] == "item_id":
199 parts.append({
200 "type": "item",
201 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
202 "flags": message_part["flags"],
203 })
204 elif message_part["type"] == "location_id":
205 parts.append({
206 "type": "location",
207 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
208 message_part["player"])
209 })
210 elif "text" in message_part:
211 parts.append({"type": "text", "text": message_part["text"]})
212
213 self.game_ctx.send_text_message(parts)
214 elif cmd == "LocationInfo":
215 if self.game_ctx.server is not None:
216 locations = []
217
218 for location in args["locations"]:
219 locations.append({
220 "id": location.location,
221 "item": self.item_names.lookup_in_slot(location.item, location.player),
222 "player": self.player_names.get(location.player),
223 "flags": location.flags,
224 "self": int(location.player) == self.slot,
225 }) 333 })
334 elif message_part["type"] == "item_id":
335 parts.append({
336 "type": "item",
337 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
338 "flags": message_part["flags"],
339 })
340 elif message_part["type"] == "location_id":
341 parts.append({
342 "type": "location",
343 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
344 message_part["player"])
345 })
346 elif "text" in message_part:
347 parts.append({"type": "text", "text": message_part["text"]})
226 348
227 self.game_ctx.send_location_info(locations) 349 self.manager.game_ctx.send_text_message(parts)
228 350 elif cmd == "LocationInfo":
229 if cmd in ["Connected", "RoomUpdate"]: 351 locations = []
230 self.game_ctx.tracker.set_checked_locations(self.checked_locations) 352
231 353 for location in args["locations"]:
232 354 locations.append({
233async def pipe_loop(ctx: Lingo2GameContext): 355 "id": location.location,
234 while not ctx.client.exit_event.is_set(): 356 "item": self.item_names.lookup_in_slot(location.item, location.player),
357 "player": self.player_names.get(location.player),
358 "flags": location.flags,
359 "self": int(location.player) == self.slot,
360 })
361
362 self.manager.game_ctx.send_location_info(locations)
363 elif cmd == "SetReply":
364 if args["key"] == self.get_datastorage_key("keyboard1"):
365 self.handle_keyboard_update(1, args)
366 elif args["key"] == self.get_datastorage_key("keyboard2"):
367 self.handle_keyboard_update(2, args)
368 elif args["key"] == self.get_datastorage_key("worldports"):
369 updates = self.manager.update_worldports(set(args["value"]))
370 if len(updates) > 0:
371 self.manager.game_ctx.send_update_worldports(updates)
372
373 def get_datastorage_key(self, name: str):
374 return f"Lingo2_{self.slot}_{name}"
375
376 async def update_keyboard(self, updates: dict[str, int]):
377 kb1 = 0
378 kb2 = 0
379
380 for k, v in updates.items():
381 if v == 0:
382 continue
383
384 effect = 0
385 if v >= 1:
386 effect |= 1
387 if v == 2:
388 effect |= 2
389
390 pos = KEY_STORAGE_MAPPING[k]
391 if pos[0] == 1:
392 kb1 |= (effect << pos[1] * 2)
393 else:
394 kb2 |= (effect << pos[1] * 2)
395
396 msgs = []
397
398 if kb1 != 0:
399 msgs.append({
400 "cmd": "Set",
401 "key": self.get_datastorage_key("keyboard1"),
402 "want_reply": True,
403 "operations": [{
404 "operation": "or",
405 "value": kb1
406 }]
407 })
408
409 if kb2 != 0:
410 msgs.append({
411 "cmd": "Set",
412 "key": self.get_datastorage_key("keyboard2"),
413 "want_reply": True,
414 "operations": [{
415 "operation": "or",
416 "value": kb2
417 }]
418 })
419
420 if len(msgs) > 0:
421 await self.send_msgs(msgs)
422
423 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
424 keys = {}
425 value = args["value"]
426
427 for i in range(0, 13):
428 if (value & (1 << (i * 2))) != 0:
429 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
430 if (value & (1 << (i * 2 + 1))) != 0:
431 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
432
433 updates = self.manager.update_keyboard(keys)
434 if len(updates) > 0:
435 self.manager.game_ctx.send_update_keyboard(updates)
436
437 async def update_worldports(self, updates: set[int]):
438 await self.send_msgs([{
439 "cmd": "Set",
440 "key": self.get_datastorage_key("worldports"),
441 "want_reply": True,
442 "operations": [{
443 "operation": "update",
444 "value": updates
445 }]
446 }])
447
448
449async def pipe_loop(manager: Lingo2Manager):
450 while not manager.client_ctx.exit_event.is_set():
235 try: 451 try:
236 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None, 452 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
237 max_size=MESSAGE_MAX_SIZE) 453 max_size=MESSAGE_MAX_SIZE)
238 ctx.server = Endpoint(socket) 454 manager.game_ctx.server = Endpoint(socket)
239 logger.info("Connected to Lingo 2!") 455 logger.info("Connected to Lingo 2!")
240 if ctx.client.auth is not None: 456 if manager.client_ctx.auth is not None:
241 ctx.send_connected() 457 manager.game_ctx.send_connected()
242 ctx.send_accessible_locations() 458 manager.game_ctx.send_accessible_locations()
243 async for data in ctx.server.socket: 459 async for data in manager.game_ctx.server.socket:
244 for msg in decode(data): 460 for msg in decode(data):
245 await process_game_cmd(ctx, msg) 461 await process_game_cmd(manager, msg)
246 except ConnectionRefusedError: 462 except ConnectionRefusedError:
247 logger.info("Could not connect to Lingo 2.") 463 logger.info("Could not connect to Lingo 2.")
248 finally: 464 finally:
249 ctx.server = None 465 manager.game_ctx.server = None
250 466
251 467
252async def process_game_cmd(ctx: Lingo2GameContext, args: dict): 468async def process_game_cmd(manager: Lingo2Manager, args: dict):
253 cmd = args["cmd"] 469 cmd = args["cmd"]
254 470
255 if cmd == "Connect": 471 if cmd == "Connect":
@@ -262,13 +478,27 @@ async def process_game_cmd(ctx: Lingo2GameContext, args: dict):
262 else: 478 else:
263 server_address = f"{player}:None@{server}" 479 server_address = f"{player}:None@{server}"
264 480
265 async_start(ctx.client.connect(server_address), name="client connect") 481 async_start(manager.client_ctx.connect(server_address), name="client connect")
266 elif cmd == "Disconnect": 482 elif cmd == "Disconnect":
267 async_start(ctx.client.disconnect(), name="client disconnect") 483 async_start(manager.client_ctx.disconnect(), name="client disconnect")
268 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]: 484 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
269 async_start(ctx.client.send_msgs([args]), name="client forward") 485 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
486 elif cmd == "UpdateKeyboard":
487 updates = manager.update_keyboard(args["keyboard"])
488 if len(updates) > 0:
489 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
490 elif cmd == "CheckWorldport":
491 port_id = args["port_id"]
492 worldports = {port_id}
493 if str(port_id) in manager.client_ctx.slot_data["port_pairings"]:
494 worldports.add(manager.client_ctx.slot_data["port_pairings"][str(port_id)])
495
496 updates = manager.update_worldports(worldports)
497 if len(updates) > 0:
498 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
499 manager.game_ctx.send_update_worldports(updates)
270 elif cmd == "Quit": 500 elif cmd == "Quit":
271 ctx.client.exit_event.set() 501 manager.client_ctx.exit_event.set()
272 502
273 503
274async def run_game(): 504async def run_game():
@@ -318,9 +548,7 @@ def client_main(*launch_args: str) -> None:
318 548
319 client_ctx = Lingo2ClientContext(args.connect, args.password) 549 client_ctx = Lingo2ClientContext(args.connect, args.password)
320 game_ctx = Lingo2GameContext() 550 game_ctx = Lingo2GameContext()
321 551 manager = Lingo2Manager(game_ctx, client_ctx)
322 client_ctx.game_ctx = game_ctx
323 game_ctx.client = client_ctx
324 552
325 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop") 553 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
326 554
@@ -328,7 +556,7 @@ def client_main(*launch_args: str) -> None:
328 client_ctx.run_gui() 556 client_ctx.run_gui()
329 client_ctx.run_cli() 557 client_ctx.run_cli()
330 558
331 pipe_task = asyncio.create_task(pipe_loop(game_ctx), name="GameWatcher") 559 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
332 560
333 try: 561 try:
334 await pipe_task 562 await pipe_task
diff --git a/apworld/locations.py b/apworld/locations.py index 108decb..a502931 100644 --- a/apworld/locations.py +++ b/apworld/locations.py
@@ -3,3 +3,5 @@ from BaseClasses import Location
3 3
4class Lingo2Location(Location): 4class Lingo2Location(Location):
5 game: str = "Lingo 2" 5 game: str = "Lingo 2"
6
7 port_id: int
diff --git a/apworld/logo.png b/apworld/logo.png new file mode 100644 index 0000000..b9d00ba --- /dev/null +++ b/apworld/logo.png
Binary files differ
diff --git a/apworld/player_logic.py b/apworld/player_logic.py index 966f712..8f2bd59 100644 --- a/apworld/player_logic.py +++ b/apworld/player_logic.py
@@ -297,13 +297,14 @@ class Lingo2PlayerLogic:
297 AccessRequirements())) 297 AccessRequirements()))
298 behavior = self.get_letter_behavior(letter.key, letter.level2) 298 behavior = self.get_letter_behavior(letter.key, letter.level2)
299 if behavior == LetterBehavior.VANILLA: 299 if behavior == LetterBehavior.VANILLA:
300 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}" 300 if not world.for_tracker:
301 event_name = f"{letter_name} (Collected)" 301 letter_name = f"{letter.key.upper()}{'2' if letter.level2 else '1'}"
302 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 302 event_name = f"{letter_name} (Collected)"
303
304 if letter.level2:
305 event_name = f"{letter_name} (Double Collected)"
306 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper() 303 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
304
305 if letter.level2:
306 event_name = f"{letter_name} (Double Collected)"
307 self.event_loc_item_by_room.setdefault(letter.room_id, {})[event_name] = letter.key.upper()
307 elif behavior == LetterBehavior.ITEM: 308 elif behavior == LetterBehavior.ITEM:
308 self.real_items.append(letter.key.upper()) 309 self.real_items.append(letter.key.upper())
309 310
diff --git a/apworld/regions.py b/apworld/regions.py index a7d9a1c..9f44682 100644 --- a/apworld/regions.py +++ b/apworld/regions.py
@@ -32,6 +32,22 @@ def create_locations(room, new_region: Region, world: "Lingo2World", regions: di
32 new_location.place_locked_item(event_item) 32 new_location.place_locked_item(event_item)
33 new_region.locations.append(new_location) 33 new_region.locations.append(new_location)
34 34
35 if world.for_tracker and world.options.shuffle_worldports:
36 for port_id in room.ports:
37 port = world.static_logic.objects.ports[port_id]
38 if port.no_shuffle:
39 continue
40
41 new_location = Lingo2Location(world.player, f"Worldport {port.id} Entered", None, new_region)
42 new_location.port_id = port.id
43
44 if port.HasField("required_door"):
45 new_location.access_rule = \
46 make_location_lambda(world.player_logic.get_door_open_reqs(port.required_door), world, regions)
47
48 new_region.locations.append(new_location)
49
50
35def create_regions(world: "Lingo2World"): 51def create_regions(world: "Lingo2World"):
36 regions = { 52 regions = {
37 "Menu": Region("Menu", world.player, world.multiworld) 53 "Menu": Region("Menu", world.player, world.multiworld)
@@ -52,7 +68,6 @@ def create_regions(world: "Lingo2World"):
52 68
53 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game") 69 regions["Menu"].connect(regions["The Entry - Starting Room"], "Start Game")
54 70
55 # TODO: The requirements of the opposite trigger also matter.
56 for connection in world.static_logic.objects.connections: 71 for connection in world.static_logic.objects.connections:
57 if connection.roof_access and not world.options.daedalus_roof_access: 72 if connection.roof_access and not world.options.daedalus_roof_access:
58 continue 73 continue
@@ -176,11 +191,17 @@ def connect_ports_from_ut(port_pairings: dict[int, int], world: "Lingo2World"):
176 191
177 connection = Entrance(world.player, f"{from_region_name} - {from_port.name}", from_region) 192 connection = Entrance(world.player, f"{from_region_name} - {from_port.name}", from_region)
178 193
194 reqs = AccessRequirements()
179 if from_port.HasField("required_door"): 195 if from_port.HasField("required_door"):
180 door_reqs = world.player_logic.get_door_open_reqs(from_port.required_door) 196 reqs = world.player_logic.get_door_open_reqs(from_port.required_door).copy()
181 connection.access_rule = make_location_lambda(door_reqs, world, None)
182 197
183 for region in door_reqs.get_referenced_rooms(): 198 if world.for_tracker:
199 reqs.items.add(f"Worldport {fpid} Entered")
200
201 if not reqs.is_empty():
202 connection.access_rule = make_location_lambda(reqs, world, None)
203
204 for region in reqs.get_referenced_rooms():
184 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player), 205 world.multiworld.register_indirect_condition(world.multiworld.get_region(region, world.player),
185 connection) 206 connection)
186 207
diff --git a/apworld/static_logic.py b/apworld/static_logic.py index e4d7d49..ef70b58 100644 --- a/apworld/static_logic.py +++ b/apworld/static_logic.py
@@ -2,6 +2,7 @@ from .generated import data_pb2 as data_pb2
2from .items import SYMBOL_ITEMS, ANTI_COLLECTABLE_TRAPS 2from .items import SYMBOL_ITEMS, ANTI_COLLECTABLE_TRAPS
3import pkgutil 3import pkgutil
4 4
5
5class Lingo2StaticLogic: 6class Lingo2StaticLogic:
6 item_id_to_name: dict[int, str] 7 item_id_to_name: dict[int, str]
7 location_id_to_name: dict[int, str] 8 location_id_to_name: dict[int, str]
diff --git a/apworld/tracker.py b/apworld/tracker.py index 721e9b3..cf2dbe1 100644 --- a/apworld/tracker.py +++ b/apworld/tracker.py
@@ -1,41 +1,54 @@
1from typing import TYPE_CHECKING
2
1from BaseClasses import MultiWorld, CollectionState, ItemClassification 3from BaseClasses import MultiWorld, CollectionState, ItemClassification
2from NetUtils import NetworkItem 4from NetUtils import NetworkItem
3from . import Lingo2World, Lingo2Item 5from . import Lingo2World, Lingo2Item
4from .regions import connect_ports_from_ut 6from .regions import connect_ports_from_ut
5from .options import Lingo2Options 7from .options import Lingo2Options, ShuffleLetters
8
9if TYPE_CHECKING:
10 from .context import Lingo2Manager
6 11
7PLAYER_NUM = 1 12PLAYER_NUM = 1
8 13
9 14
10class Tracker: 15class Tracker:
16 manager: "Lingo2Manager"
17
11 multiworld: MultiWorld 18 multiworld: MultiWorld
19 world: Lingo2World
12 20
13 collected_items: dict[int, int] 21 collected_items: dict[int, int]
14 checked_locations: set[int] 22 checked_locations: set[int]
15 accessible_locations: set[int] 23 accessible_locations: set[int]
24 accessible_worldports: set[int]
16 25
17 state: CollectionState 26 state: CollectionState
18 27
19 def __init__(self): 28 def __init__(self, manager: "Lingo2Manager"):
29 self.manager = manager
20 self.collected_items = {} 30 self.collected_items = {}
21 self.checked_locations = set() 31 self.checked_locations = set()
22 self.accessible_locations = set() 32 self.accessible_locations = set()
33 self.accessible_worldports = set()
23 34
24 def setup_slot(self, slot_data): 35 def setup_slot(self, slot_data):
36 Lingo2World.for_tracker = True
37
25 self.multiworld = MultiWorld(players=PLAYER_NUM) 38 self.multiworld = MultiWorld(players=PLAYER_NUM)
26 world = Lingo2World(self.multiworld, PLAYER_NUM) 39 self.world = Lingo2World(self.multiworld, PLAYER_NUM)
27 self.multiworld.worlds[1] = world 40 self.multiworld.worlds[1] = self.world
28 world.options = Lingo2Options(**{k: t(slot_data.get(k, t.default)) 41 self.world.options = Lingo2Options(**{k: t(slot_data.get(k, t.default))
29 for k, t in Lingo2Options.type_hints.items()}) 42 for k, t in Lingo2Options.type_hints.items()})
30 43
31 world.generate_early() 44 self.world.generate_early()
32 world.create_regions() 45 self.world.create_regions()
33 46
34 if world.options.shuffle_worldports: 47 if self.world.options.shuffle_worldports:
35 port_pairings = {int(fp): int(tp) for fp, tp in slot_data["port_pairings"].items()} 48 port_pairings = {int(fp): int(tp) for fp, tp in slot_data["port_pairings"].items()}
36 connect_ports_from_ut(port_pairings, world) 49 connect_ports_from_ut(port_pairings, self.world)
37 50
38 self.state = CollectionState(self.multiworld) 51 self.refresh_state()
39 52
40 def set_checked_locations(self, checked_locations: set[int]): 53 def set_checked_locations(self, checked_locations: set[int]):
41 self.checked_locations = checked_locations.copy() 54 self.checked_locations = checked_locations.copy()
@@ -56,12 +69,38 @@ class Tracker:
56 self.state.collect(Lingo2Item(Lingo2World.static_logic.item_id_to_name.get(item_id), 69 self.state.collect(Lingo2Item(Lingo2World.static_logic.item_id_to_name.get(item_id),
57 ItemClassification.progression, item_id, PLAYER_NUM), prevent_sweep=True) 70 ItemClassification.progression, item_id, PLAYER_NUM), prevent_sweep=True)
58 71
72 for k, v in self.manager.keyboard.items():
73 # Unless all level 1 letters are pre-unlocked, H1 I1 N1 and T1 act differently between the generator and
74 # game. The generator considers them to be unlocked, which means they are not included in logic
75 # requirements, and only one item/event is needed to unlock their level 2 forms. The game considers them to
76 # be vanilla, which means you still have to pick them up in the Starting Room in order for them to appear on
77 # your keyboard. This also means that whether or not you have the level 1 forms should be synced to the
78 # multiworld. The tracker specifically should collect one fewer item for these letters in this scenario.
79 tv = v
80 if k in "hint" and self.world.options.shuffle_letters in [ShuffleLetters.option_vanilla,
81 ShuffleLetters.option_progressive]:
82 tv = max(0, v - 1)
83
84 if tv > 0:
85 for i in range(tv):
86 self.state.collect(Lingo2Item(k.upper(), ItemClassification.progression, None, PLAYER_NUM),
87 prevent_sweep=True)
88
89 for port_id in self.manager.worldports:
90 self.state.collect(Lingo2Item(f"Worldport {port_id} Entered", ItemClassification.progression, None,
91 PLAYER_NUM), prevent_sweep=True)
92
59 self.state.sweep_for_advancements() 93 self.state.sweep_for_advancements()
60 94
61 self.accessible_locations = set() 95 self.accessible_locations = set()
96 self.accessible_worldports = set()
62 97
63 for region in self.state.reachable_regions[PLAYER_NUM]: 98 for region in self.state.reachable_regions[PLAYER_NUM]:
64 for location in region.locations: 99 for location in region.locations:
65 if location.address not in self.checked_locations and location.access_rule(self.state): 100 if location.access_rule(self.state):
66 if location.address is not None: 101 if location.address is not None:
67 self.accessible_locations.add(location.address) 102 if location.address not in self.checked_locations:
103 self.accessible_locations.add(location.address)
104 elif hasattr(location, "port_id"):
105 if location.port_id not in self.manager.worldports:
106 self.accessible_worldports.add(location.port_id)