about summary refs log tree commit diff stats
path: root/apworld/context.py
diff options
context:
space:
mode:
authorStar Rauchenberger <fefferburbia@gmail.com>2025-09-27 17:14:40 -0400
committerStar Rauchenberger <fefferburbia@gmail.com>2025-09-27 17:14:40 -0400
commitb0f474bee1c8e1111f7542bf4985136d9aedf340 (patch)
treeef2aa34bad532ffb2a45d90893dbcd4c378a0dfb /apworld/context.py
parentfeb89a44ddf5f93bc476ca29cd02257aea47dc06 (diff)
downloadlingo2-archipelago-b0f474bee1c8e1111f7542bf4985136d9aedf340.tar.gz
lingo2-archipelago-b0f474bee1c8e1111f7542bf4985136d9aedf340.tar.bz2
lingo2-archipelago-b0f474bee1c8e1111f7542bf4985136d9aedf340.zip
Treat local letters as items for tracker
Local letters are now synced with datastorage, so they transfer to other computers like regular items would, and the tracker also now waits until you collect local letters before showing what they give you in logic.
Diffstat (limited to 'apworld/context.py')
-rw-r--r--apworld/context.py388
1 files changed, 276 insertions, 112 deletions
diff --git a/apworld/context.py b/apworld/context.py index 0a058e5..bc3b1bf 100644 --- a/apworld/context.py +++ b/apworld/context.py
@@ -15,35 +15,85 @@ 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
39 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"):
40 self.game_ctx = game_ctx
41 self.game_ctx.manager = self
42 self.client_ctx = client_ctx
43 self.client_ctx.manager = self
44 self.tracker = Tracker(self)
45 self.keyboard = {}
46
47 self.reset()
48
49 def reset(self):
50 for k in ALL_LETTERS:
51 self.keyboard[k] = 0
52
53 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]:
54 ret: dict[str, int] = {}
55
56 for k, v in new_keyboard.items():
57 if v > self.keyboard.get(k, 0):
58 self.keyboard[k] = v
59 ret[k] = v
60
61 if len(ret) > 0:
62 self.tracker.refresh_state()
63 self.game_ctx.send_accessible_locations()
64
65 return ret
20 66
21 67
22class Lingo2GameContext: 68class Lingo2GameContext:
23 server: Endpoint | None 69 server: Endpoint | None
24 client: "Lingo2ClientContext" 70 manager: Lingo2Manager
25 tracker: Tracker
26 71
27 def __init__(self): 72 def __init__(self):
28 self.server = None 73 self.server = None
29 self.tracker = Tracker()
30 74
31 def send_connected(self): 75 def send_connected(self):
76 if self.server is None:
77 return
78
32 msg = { 79 msg = {
33 "cmd": "Connected", 80 "cmd": "Connected",
34 "user": self.client.username, 81 "user": self.manager.client_ctx.username,
35 "seed_name": self.client.seed_name, 82 "seed_name": self.manager.client_ctx.seed_name,
36 "version": self.client.server_version, 83 "version": self.manager.client_ctx.server_version,
37 "generator_version": self.client.generator_version, 84 "generator_version": self.manager.client_ctx.generator_version,
38 "team": self.client.team, 85 "team": self.manager.client_ctx.team,
39 "slot": self.client.slot, 86 "slot": self.manager.client_ctx.slot,
40 "checked_locations": self.client.checked_locations, 87 "checked_locations": self.manager.client_ctx.checked_locations,
41 "slot_data": self.client.slot_data, 88 "slot_data": self.manager.client_ctx.slot_data,
42 } 89 }
43 90
44 async_start(self.send_msgs([msg]), name="game Connected") 91 async_start(self.send_msgs([msg]), name="game Connected")
45 92
46 def send_item_sent_notification(self, item_name, receiver_name, item_flags): 93 def send_item_sent_notification(self, item_name, receiver_name, item_flags):
94 if self.server is None:
95 return
96
47 msg = { 97 msg = {
48 "cmd": "ItemSentNotif", 98 "cmd": "ItemSentNotif",
49 "item_name": item_name, 99 "item_name": item_name,
@@ -54,6 +104,9 @@ class Lingo2GameContext:
54 async_start(self.send_msgs([msg]), name="item sent notif") 104 async_start(self.send_msgs([msg]), name="item sent notif")
55 105
56 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self): 106 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self):
107 if self.server is None:
108 return
109
57 msg = { 110 msg = {
58 "cmd": "HintReceived", 111 "cmd": "HintReceived",
59 "item_name": item_name, 112 "item_name": item_name,
@@ -66,6 +119,9 @@ class Lingo2GameContext:
66 async_start(self.send_msgs([msg]), name="hint received notif") 119 async_start(self.send_msgs([msg]), name="hint received notif")
67 120
68 def send_item_received(self, items): 121 def send_item_received(self, items):
122 if self.server is None:
123 return
124
69 msg = { 125 msg = {
70 "cmd": "ItemReceived", 126 "cmd": "ItemReceived",
71 "items": items, 127 "items": items,
@@ -74,6 +130,9 @@ class Lingo2GameContext:
74 async_start(self.send_msgs([msg]), name="item received") 130 async_start(self.send_msgs([msg]), name="item received")
75 131
76 def send_location_info(self, locations): 132 def send_location_info(self, locations):
133 if self.server is None:
134 return
135
77 msg = { 136 msg = {
78 "cmd": "LocationInfo", 137 "cmd": "LocationInfo",
79 "locations": locations, 138 "locations": locations,
@@ -82,6 +141,9 @@ class Lingo2GameContext:
82 async_start(self.send_msgs([msg]), name="location info") 141 async_start(self.send_msgs([msg]), name="location info")
83 142
84 def send_text_message(self, parts): 143 def send_text_message(self, parts):
144 if self.server is None:
145 return
146
85 msg = { 147 msg = {
86 "cmd": "TextMessage", 148 "cmd": "TextMessage",
87 "data": parts, 149 "data": parts,
@@ -90,14 +152,20 @@ class Lingo2GameContext:
90 async_start(self.send_msgs([msg]), name="notif") 152 async_start(self.send_msgs([msg]), name="notif")
91 153
92 def send_accessible_locations(self): 154 def send_accessible_locations(self):
155 if self.server is None:
156 return
157
93 msg = { 158 msg = {
94 "cmd": "AccessibleLocations", 159 "cmd": "AccessibleLocations",
95 "locations": list(self.tracker.accessible_locations), 160 "locations": list(self.manager.tracker.accessible_locations),
96 } 161 }
97 162
98 async_start(self.send_msgs([msg]), name="accessible locations") 163 async_start(self.send_msgs([msg]), name="accessible locations")
99 164
100 def send_update_locations(self, locations): 165 def send_update_locations(self, locations):
166 if self.server is None:
167 return
168
101 msg = { 169 msg = {
102 "cmd": "UpdateLocations", 170 "cmd": "UpdateLocations",
103 "locations": locations, 171 "locations": locations,
@@ -105,6 +173,17 @@ class Lingo2GameContext:
105 173
106 async_start(self.send_msgs([msg]), name="update locations") 174 async_start(self.send_msgs([msg]), name="update locations")
107 175
176 def send_update_keyboard(self, updates):
177 if self.server is None:
178 return
179
180 msg = {
181 "cmd": "UpdateKeyboard",
182 "updates": updates,
183 }
184
185 async_start(self.send_msgs([msg]), name="update keyboard")
186
108 async def send_msgs(self, msgs: list[Any]) -> None: 187 async def send_msgs(self, msgs: list[Any]) -> None:
109 """ `msgs` JSON serializable """ 188 """ `msgs` JSON serializable """
110 if not self.server or not self.server.socket.open or self.server.socket.closed: 189 if not self.server or not self.server.socket.open or self.server.socket.closed:
@@ -113,7 +192,7 @@ class Lingo2GameContext:
113 192
114 193
115class Lingo2ClientContext(CommonContext): 194class Lingo2ClientContext(CommonContext):
116 game_ctx: Lingo2GameContext 195 manager: Lingo2Manager
117 196
118 game = "Lingo 2" 197 game = "Lingo 2"
119 items_handling = 0b111 198 items_handling = 0b111
@@ -138,118 +217,201 @@ class Lingo2ClientContext(CommonContext):
138 elif cmd == "Connected": 217 elif cmd == "Connected":
139 self.slot_data = args.get("slot_data", None) 218 self.slot_data = args.get("slot_data", None)
140 219
141 if self.game_ctx.server is not None: 220 self.manager.reset()
142 self.game_ctx.send_connected() 221
143 222 self.manager.game_ctx.send_connected()
144 self.game_ctx.tracker.setup_slot(self.slot_data) 223
224 self.manager.tracker.setup_slot(self.slot_data)
225 self.manager.tracker.set_checked_locations(self.checked_locations)
226 self.manager.game_ctx.send_accessible_locations()
227
228 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"))
229 async_start(self.send_msgs([{
230 "cmd": "Set",
231 "key": self.get_datastorage_key("keyboard1"),
232 "default": 0,
233 "want_reply": True,
234 "operations": [{"operation": "default", "value": 0}]
235 }, {
236 "cmd": "Set",
237 "key": self.get_datastorage_key("keyboard2"),
238 "default": 0,
239 "want_reply": True,
240 "operations": [{"operation": "default", "value": 0}]
241 }]), name="default keys")
145 elif cmd == "RoomUpdate": 242 elif cmd == "RoomUpdate":
146 if self.game_ctx.server is not None: 243 self.manager.tracker.set_checked_locations(self.checked_locations)
147 self.game_ctx.send_update_locations(args["checked_locations"]) 244 self.manager.game_ctx.send_update_locations(args["checked_locations"])
148 elif cmd == "ReceivedItems": 245 elif cmd == "ReceivedItems":
149 self.game_ctx.tracker.set_collected_items(self.items_received) 246 self.manager.tracker.set_collected_items(self.items_received)
150 247
151 if self.game_ctx.server is not None: 248 cur_index = 0
152 cur_index = 0 249 items = []
153 items = []
154 250
155 for item in args["items"]: 251 for item in args["items"]:
156 index = cur_index + args["index"] 252 index = cur_index + args["index"]
157 cur_index += 1 253 cur_index += 1
158 254
159 item_msg = { 255 item_msg = {
160 "id": item.item, 256 "id": item.item,
161 "index": index, 257 "index": index,
162 "flags": item.flags, 258 "flags": item.flags,
163 "text": self.item_names.lookup_in_slot(item.item, self.slot), 259 "text": self.item_names.lookup_in_slot(item.item, self.slot),
164 } 260 }
165 261
166 if item.player != self.slot: 262 if item.player != self.slot:
167 item_msg["sender"] = self.player_names.get(item.player) 263 item_msg["sender"] = self.player_names.get(item.player)
168 264
169 items.append(item_msg) 265 items.append(item_msg)
170 266
171 self.game_ctx.send_item_received(items) 267 self.manager.game_ctx.send_item_received(items)
172 268
173 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]): 269 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
174 self.game_ctx.send_accessible_locations() 270 self.manager.game_ctx.send_accessible_locations()
175 elif cmd == "PrintJSON": 271 elif cmd == "PrintJSON":
176 if self.game_ctx.server is not None: 272 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: 273 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"]) 274 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) 275 receiver_name = self.player_names.get(args["receiving"])
180 receiver_name = self.player_names.get(args["receiving"]) 276
181 277 if args["type"] == "Hint" and not args.get("found", False):
182 if args["type"] == "Hint" and not args.get("found", False): 278 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, 279 int(args["receiving"]) == self.slot)
184 int(args["receiving"]) == self.slot) 280 elif args["receiving"] != self.slot:
185 elif args["receiving"] != self.slot: 281 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) 282
187 283 parts = []
188 parts = [] 284 for message_part in args["data"]:
189 for message_part in args["data"]: 285 if "type" not in message_part and "text" in message_part:
190 if "type" not in message_part and "text" in message_part: 286 parts.append({"type": "text", "text": message_part["text"]})
191 parts.append({"type": "text", "text": message_part["text"]}) 287 elif message_part["type"] == "player_id":
192 elif message_part["type"] == "player_id": 288 parts.append({
193 parts.append({ 289 "type": "player",
194 "type": "player", 290 "text": self.player_names.get(int(message_part["text"])),
195 "text": self.player_names.get(int(message_part["text"])), 291 "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 }) 292 })
293 elif message_part["type"] == "item_id":
294 parts.append({
295 "type": "item",
296 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
297 "flags": message_part["flags"],
298 })
299 elif message_part["type"] == "location_id":
300 parts.append({
301 "type": "location",
302 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
303 message_part["player"])
304 })
305 elif "text" in message_part:
306 parts.append({"type": "text", "text": message_part["text"]})
226 307
227 self.game_ctx.send_location_info(locations) 308 self.manager.game_ctx.send_text_message(parts)
228 309 elif cmd == "LocationInfo":
229 if cmd in ["Connected", "RoomUpdate"]: 310 locations = []
230 self.game_ctx.tracker.set_checked_locations(self.checked_locations) 311
231 312 for location in args["locations"]:
232 313 locations.append({
233async def pipe_loop(ctx: Lingo2GameContext): 314 "id": location.location,
234 while not ctx.client.exit_event.is_set(): 315 "item": self.item_names.lookup_in_slot(location.item, location.player),
316 "player": self.player_names.get(location.player),
317 "flags": location.flags,
318 "self": int(location.player) == self.slot,
319 })
320
321 self.manager.game_ctx.send_location_info(locations)
322 elif cmd == "SetReply":
323 if args["key"] == self.get_datastorage_key("keyboard1"):
324 self.handle_keyboard_update(1, args)
325 elif args["key"] == self.get_datastorage_key("keyboard2"):
326 self.handle_keyboard_update(2, args)
327
328 def get_datastorage_key(self, name: str):
329 return f"Lingo2_{self.slot}_{name}"
330
331 async def update_keyboard(self, updates: dict[str, int]):
332 kb1 = 0
333 kb2 = 0
334
335 for k, v in updates.items():
336 if v == 0:
337 continue
338
339 effect = 0
340 if v >= 1:
341 effect |= 1
342 if v == 2:
343 effect |= 2
344
345 pos = KEY_STORAGE_MAPPING[k]
346 if pos[0] == 1:
347 kb1 |= (effect << pos[1] * 2)
348 else:
349 kb2 |= (effect << pos[1] * 2)
350
351 msgs = []
352
353 if kb1 != 0:
354 msgs.append({
355 "cmd": "Set",
356 "key": self.get_datastorage_key("keyboard1"),
357 "want_reply": True,
358 "operations": [{
359 "operation": "or",
360 "value": kb1
361 }]
362 })
363
364 if kb2 != 0:
365 msgs.append({
366 "cmd": "Set",
367 "key": self.get_datastorage_key("keyboard2"),
368 "want_reply": True,
369 "operations": [{
370 "operation": "or",
371 "value": kb2
372 }]
373 })
374
375 if len(msgs) > 0:
376 print(updates)
377 print(msgs)
378 await self.send_msgs(msgs)
379
380 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
381 keys = {}
382 value = args["value"]
383
384 for i in range(0, 13):
385 if (value & (1 << (i * 2))) != 0:
386 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
387 if (value & (1 << (i * 2 + 1))) != 0:
388 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
389
390 updates = self.manager.update_keyboard(keys)
391 if len(updates) > 0:
392 self.manager.game_ctx.send_update_keyboard(updates)
393
394
395async def pipe_loop(manager: Lingo2Manager):
396 while not manager.client_ctx.exit_event.is_set():
235 try: 397 try:
236 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None, 398 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
237 max_size=MESSAGE_MAX_SIZE) 399 max_size=MESSAGE_MAX_SIZE)
238 ctx.server = Endpoint(socket) 400 manager.game_ctx.server = Endpoint(socket)
239 logger.info("Connected to Lingo 2!") 401 logger.info("Connected to Lingo 2!")
240 if ctx.client.auth is not None: 402 if manager.client_ctx.auth is not None:
241 ctx.send_connected() 403 manager.game_ctx.send_connected()
242 ctx.send_accessible_locations() 404 manager.game_ctx.send_accessible_locations()
243 async for data in ctx.server.socket: 405 async for data in manager.game_ctx.server.socket:
244 for msg in decode(data): 406 for msg in decode(data):
245 await process_game_cmd(ctx, msg) 407 await process_game_cmd(manager, msg)
246 except ConnectionRefusedError: 408 except ConnectionRefusedError:
247 logger.info("Could not connect to Lingo 2.") 409 logger.info("Could not connect to Lingo 2.")
248 finally: 410 finally:
249 ctx.server = None 411 manager.game_ctx.server = None
250 412
251 413
252async def process_game_cmd(ctx: Lingo2GameContext, args: dict): 414async def process_game_cmd(manager: Lingo2Manager, args: dict):
253 cmd = args["cmd"] 415 cmd = args["cmd"]
254 416
255 if cmd == "Connect": 417 if cmd == "Connect":
@@ -262,13 +424,17 @@ async def process_game_cmd(ctx: Lingo2GameContext, args: dict):
262 else: 424 else:
263 server_address = f"{player}:None@{server}" 425 server_address = f"{player}:None@{server}"
264 426
265 async_start(ctx.client.connect(server_address), name="client connect") 427 async_start(manager.client_ctx.connect(server_address), name="client connect")
266 elif cmd == "Disconnect": 428 elif cmd == "Disconnect":
267 async_start(ctx.client.disconnect(), name="client disconnect") 429 async_start(manager.client_ctx.disconnect(), name="client disconnect")
268 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]: 430 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
269 async_start(ctx.client.send_msgs([args]), name="client forward") 431 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
432 elif cmd == "UpdateKeyboard":
433 updates = manager.update_keyboard(args["keyboard"])
434 if len(updates) > 0:
435 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
270 elif cmd == "Quit": 436 elif cmd == "Quit":
271 ctx.client.exit_event.set() 437 manager.client_ctx.exit_event.set()
272 438
273 439
274async def run_game(): 440async def run_game():
@@ -318,9 +484,7 @@ def client_main(*launch_args: str) -> None:
318 484
319 client_ctx = Lingo2ClientContext(args.connect, args.password) 485 client_ctx = Lingo2ClientContext(args.connect, args.password)
320 game_ctx = Lingo2GameContext() 486 game_ctx = Lingo2GameContext()
321 487 manager = Lingo2Manager(game_ctx, client_ctx)
322 client_ctx.game_ctx = game_ctx
323 game_ctx.client = client_ctx
324 488
325 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop") 489 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
326 490
@@ -328,7 +492,7 @@ def client_main(*launch_args: str) -> None:
328 client_ctx.run_gui() 492 client_ctx.run_gui()
329 client_ctx.run_cli() 493 client_ctx.run_cli()
330 494
331 pipe_task = asyncio.create_task(pipe_loop(game_ctx), name="GameWatcher") 495 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
332 496
333 try: 497 try:
334 await pipe_task 498 await pipe_task