about summary refs log tree commit diff stats
path: root/apworld/context.py
diff options
context:
space:
mode:
Diffstat (limited to 'apworld/context.py')
-rw-r--r--apworld/context.py654
1 files changed, 654 insertions, 0 deletions
diff --git a/apworld/context.py b/apworld/context.py new file mode 100644 index 0000000..a0ee34d --- /dev/null +++ b/apworld/context.py
@@ -0,0 +1,654 @@
1import asyncio
2import os
3import pkgutil
4import subprocess
5import sys
6from typing import Any
7
8import websockets
9
10import Utils
11import settings
12from BaseClasses import ItemClassification
13from CommonClient import CommonContext, server_loop, gui_enabled, logger, get_base_parser, handle_url_arg
14from NetUtils import Endpoint, decode, encode, ClientStatus
15from Utils import async_start
16from . import Lingo2World
17from .tracker import Tracker
18
19ALL_LETTERS = "abcdefghijklmnopqrstuvwxyz"
20MESSAGE_MAX_SIZE = 16*1024*1024
21PORT = 43182
22
23KEY_STORAGE_MAPPING = {
24 "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),
25 "j": (1, 9), "k": (1, 10), "l": (1, 11), "m": (1, 12), "n": (2, 0), "o": (2, 1), "p": (2, 2), "q": (2, 3),
26 "r": (2, 4), "s": (2, 5), "t": (2, 6), "u": (2, 7), "v": (2, 8), "w": (2, 9), "x": (2, 10), "y": (2, 11),
27 "z": (2, 12),
28}
29
30REVERSE_KEY_STORAGE_MAPPING = {t: k for k, t in KEY_STORAGE_MAPPING.items()}
31
32
33class Lingo2Manager:
34 game_ctx: "Lingo2GameContext"
35 client_ctx: "Lingo2ClientContext"
36 tracker: Tracker
37
38 keyboard: dict[str, int]
39 worldports: set[int]
40 goaled: bool
41
42 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"):
43 self.game_ctx = game_ctx
44 self.game_ctx.manager = self
45 self.client_ctx = client_ctx
46 self.client_ctx.manager = self
47 self.tracker = Tracker(self)
48 self.keyboard = {}
49 self.worldports = set()
50
51 self.reset()
52
53 def reset(self):
54 for k in ALL_LETTERS:
55 self.keyboard[k] = 0
56
57 self.worldports = set()
58 self.goaled = False
59
60 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]:
61 ret: dict[str, int] = {}
62
63 for k, v in new_keyboard.items():
64 if v > self.keyboard.get(k, 0):
65 self.keyboard[k] = v
66 ret[k] = v
67
68 if len(ret) > 0:
69 self.tracker.refresh_state()
70 self.game_ctx.send_accessible_locations()
71
72 return ret
73
74 def update_worldports(self, new_worldports: set[int]) -> set[int]:
75 ret = new_worldports.difference(self.worldports)
76 self.worldports.update(new_worldports)
77
78 if len(ret) > 0:
79 self.tracker.refresh_state()
80 self.game_ctx.send_accessible_locations()
81
82 return ret
83
84
85class Lingo2GameContext:
86 server: Endpoint | None
87 manager: Lingo2Manager
88
89 def __init__(self):
90 self.server = None
91
92 def send_connected(self):
93 if self.server is None:
94 return
95
96 msg = {
97 "cmd": "Connected",
98 "user": self.manager.client_ctx.username,
99 "seed_name": self.manager.client_ctx.seed_name,
100 "version": self.manager.client_ctx.server_version,
101 "generator_version": self.manager.client_ctx.generator_version,
102 "team": self.manager.client_ctx.team,
103 "slot": self.manager.client_ctx.slot,
104 "checked_locations": self.manager.client_ctx.checked_locations,
105 "slot_data": self.manager.client_ctx.slot_data,
106 }
107
108 async_start(self.send_msgs([msg]), name="game Connected")
109
110 def send_connection_refused(self, text):
111 if self.server is None:
112 return
113
114 msg = {
115 "cmd": "ConnectionRefused",
116 "text": text,
117 }
118
119 async_start(self.send_msgs([msg]), name="game ConnectionRefused")
120
121 def send_item_sent_notification(self, item_name, receiver_name, item_flags):
122 if self.server is None:
123 return
124
125 msg = {
126 "cmd": "ItemSentNotif",
127 "item_name": item_name,
128 "receiver_name": receiver_name,
129 "item_flags": item_flags,
130 }
131
132 async_start(self.send_msgs([msg]), name="item sent notif")
133
134 def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self):
135 if self.server is None:
136 return
137
138 msg = {
139 "cmd": "HintReceived",
140 "item_name": item_name,
141 "location_name": location_name,
142 "receiver_name": receiver_name,
143 "item_flags": item_flags,
144 "self": int(for_self),
145 }
146
147 async_start(self.send_msgs([msg]), name="hint received notif")
148
149 def send_item_received(self, items):
150 if self.server is None:
151 return
152
153 msg = {
154 "cmd": "ItemReceived",
155 "items": items,
156 }
157
158 async_start(self.send_msgs([msg]), name="item received")
159
160 def send_location_info(self, locations):
161 if self.server is None:
162 return
163
164 msg = {
165 "cmd": "LocationInfo",
166 "locations": locations,
167 }
168
169 async_start(self.send_msgs([msg]), name="location info")
170
171 def send_text_message(self, parts):
172 if self.server is None:
173 return
174
175 msg = {
176 "cmd": "TextMessage",
177 "data": parts,
178 }
179
180 async_start(self.send_msgs([msg]), name="notif")
181
182 def send_accessible_locations(self):
183 if self.server is None:
184 return
185
186 msg = {
187 "cmd": "AccessibleLocations",
188 "locations": list(self.manager.tracker.accessible_locations),
189 }
190
191 if len(self.manager.tracker.accessible_worldports) > 0:
192 msg["worldports"] = list(self.manager.tracker.accessible_worldports)
193
194 if self.manager.tracker.goal_accessible and not self.manager.goaled:
195 msg["goal"] = True
196
197 async_start(self.send_msgs([msg]), name="accessible locations")
198
199 def send_update_locations(self, locations):
200 if self.server is None:
201 return
202
203 msg = {
204 "cmd": "UpdateLocations",
205 "locations": locations,
206 }
207
208 async_start(self.send_msgs([msg]), name="update locations")
209
210 def send_update_keyboard(self, updates):
211 if self.server is None:
212 return
213
214 msg = {
215 "cmd": "UpdateKeyboard",
216 "updates": updates,
217 }
218
219 async_start(self.send_msgs([msg]), name="update keyboard")
220
221 def send_update_worldports(self, worldports):
222 if self.server is None:
223 return
224
225 msg = {
226 "cmd": "UpdateWorldports",
227 "worldports": worldports,
228 }
229
230 async_start(self.send_msgs([msg]), name="update worldports")
231
232 def send_path_reply(self, object_type: str, object_id: int | None, path: list[str]):
233 if self.server is None:
234 return
235
236 msg = {
237 "cmd": "PathReply",
238 "type": object_type,
239 "path": path,
240 }
241
242 if object_id is not None:
243 msg["id"] = object_id
244
245 async_start(self.send_msgs([msg]), name="path reply")
246
247 async def send_msgs(self, msgs: list[Any]) -> None:
248 """ `msgs` JSON serializable """
249 if not self.server or not self.server.socket.open or self.server.socket.closed:
250 return
251 await self.server.socket.send(encode(msgs))
252
253
254class Lingo2ClientContext(CommonContext):
255 manager: Lingo2Manager
256
257 game = "Lingo 2"
258 items_handling = 0b111
259
260 slot_data: dict[str, Any] | None
261 victory_data_storage_key: str
262
263 def __init__(self, server_address: str | None = None, password: str | None = None):
264 super().__init__(server_address, password)
265
266 def make_gui(self):
267 ui = super().make_gui()
268 ui.base_title = "Archipelago Lingo 2 Client"
269 return ui
270
271 async def server_auth(self, password_requested: bool = False):
272 if password_requested and not self.password:
273 self.manager.game_ctx.send_connection_refused("Invalid password.")
274 else:
275 self.auth = self.username
276 await self.send_connect()
277
278 def handle_connection_loss(self, msg: str):
279 super().handle_connection_loss(msg)
280
281 exc_info = sys.exc_info()
282 self.manager.game_ctx.send_connection_refused(str(exc_info[1]))
283
284 def on_package(self, cmd: str, args: dict):
285 if cmd == "RoomInfo":
286 self.seed_name = args.get("seed_name", None)
287 elif cmd == "Connected":
288 self.slot_data = args.get("slot_data", None)
289
290 self.manager.reset()
291
292 self.manager.game_ctx.send_connected()
293
294 self.manager.tracker.setup_slot(self.slot_data)
295 self.manager.tracker.set_checked_locations(self.checked_locations)
296 self.manager.game_ctx.send_accessible_locations()
297
298 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}"
299
300 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"),
301 self.victory_data_storage_key)
302 msg_batch = [{
303 "cmd": "Set",
304 "key": self.get_datastorage_key("keyboard1"),
305 "default": 0,
306 "want_reply": True,
307 "operations": [{"operation": "default", "value": 0}]
308 }, {
309 "cmd": "Set",
310 "key": self.get_datastorage_key("keyboard2"),
311 "default": 0,
312 "want_reply": True,
313 "operations": [{"operation": "default", "value": 0}]
314 }]
315
316 if self.slot_data.get("shuffle_worldports", False):
317 self.set_notify(self.get_datastorage_key("worldports"))
318 msg_batch.append({
319 "cmd": "Set",
320 "key": self.get_datastorage_key("worldports"),
321 "default": [],
322 "want_reply": True,
323 "operations": [{"operation": "default", "value": []}]
324 })
325
326 async_start(self.send_msgs(msg_batch), name="default keys")
327 elif cmd == "RoomUpdate":
328 if "checked_locations" in args:
329 self.manager.tracker.set_checked_locations(self.checked_locations)
330 self.manager.game_ctx.send_update_locations(args["checked_locations"])
331 elif cmd == "ReceivedItems":
332 self.manager.tracker.set_collected_items(self.items_received)
333
334 cur_index = 0
335 items = []
336
337 for item in args["items"]:
338 index = cur_index + args["index"]
339 cur_index += 1
340
341 item_msg = {
342 "id": item.item,
343 "index": index,
344 "flags": item.flags,
345 "text": self.item_names.lookup_in_slot(item.item, self.slot),
346 }
347
348 if item.player != self.slot:
349 item_msg["sender"] = self.player_names.get(item.player)
350
351 items.append(item_msg)
352
353 self.manager.game_ctx.send_item_received(items)
354
355 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
356 self.manager.game_ctx.send_accessible_locations()
357 elif cmd == "PrintJSON":
358 if "receiving" in args and "item" in args and args["item"].player == self.slot:
359 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"])
360 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player)
361 receiver_name = self.player_names.get(args["receiving"])
362
363 if args["type"] == "Hint" and not args.get("found", False):
364 self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags,
365 int(args["receiving"]) == self.slot)
366 elif args["receiving"] != self.slot:
367 self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags)
368
369 parts = []
370 for message_part in args["data"]:
371 if "type" not in message_part and "text" in message_part:
372 parts.append({"type": "text", "text": message_part["text"]})
373 elif message_part["type"] == "player_id":
374 parts.append({
375 "type": "player",
376 "text": self.player_names.get(int(message_part["text"])),
377 "self": int(int(message_part["text"]) == self.slot),
378 })
379 elif message_part["type"] == "item_id":
380 parts.append({
381 "type": "item",
382 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
383 "flags": message_part["flags"],
384 })
385 elif message_part["type"] == "location_id":
386 parts.append({
387 "type": "location",
388 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
389 message_part["player"])
390 })
391 elif "text" in message_part:
392 parts.append({"type": "text", "text": message_part["text"]})
393
394 self.manager.game_ctx.send_text_message(parts)
395 elif cmd == "LocationInfo":
396 locations = []
397
398 for location in args["locations"]:
399 locations.append({
400 "id": location.location,
401 "item": self.item_names.lookup_in_slot(location.item, location.player),
402 "player": self.player_names.get(location.player),
403 "flags": location.flags,
404 "self": int(location.player) == self.slot,
405 })
406
407 self.manager.game_ctx.send_location_info(locations)
408 elif cmd == "Retrieved":
409 for k, v in args["keys"].items():
410 if k == self.victory_data_storage_key:
411 self.handle_status_update(v)
412 elif cmd == "SetReply":
413 if args["key"] == self.get_datastorage_key("keyboard1"):
414 self.handle_keyboard_update(1, args)
415 elif args["key"] == self.get_datastorage_key("keyboard2"):
416 self.handle_keyboard_update(2, args)
417 elif args["key"] == self.get_datastorage_key("worldports"):
418 updates = self.manager.update_worldports(set(args["value"]))
419 if len(updates) > 0:
420 self.manager.game_ctx.send_update_worldports(updates)
421 elif args["key"] == self.victory_data_storage_key:
422 self.handle_status_update(args["value"])
423
424 def get_datastorage_key(self, name: str):
425 return f"Lingo2_{self.slot}_{name}"
426
427 async def update_keyboard(self, updates: dict[str, int]):
428 kb1 = 0
429 kb2 = 0
430
431 for k, v in updates.items():
432 if v == 0:
433 continue
434
435 effect = 0
436 if v >= 1:
437 effect |= 1
438 if v == 2:
439 effect |= 2
440
441 pos = KEY_STORAGE_MAPPING[k]
442 if pos[0] == 1:
443 kb1 |= (effect << pos[1] * 2)
444 else:
445 kb2 |= (effect << pos[1] * 2)
446
447 msgs = []
448
449 if kb1 != 0:
450 msgs.append({
451 "cmd": "Set",
452 "key": self.get_datastorage_key("keyboard1"),
453 "want_reply": True,
454 "operations": [{
455 "operation": "or",
456 "value": kb1
457 }]
458 })
459
460 if kb2 != 0:
461 msgs.append({
462 "cmd": "Set",
463 "key": self.get_datastorage_key("keyboard2"),
464 "want_reply": True,
465 "operations": [{
466 "operation": "or",
467 "value": kb2
468 }]
469 })
470
471 if len(msgs) > 0:
472 await self.send_msgs(msgs)
473
474 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
475 keys = {}
476 value = args["value"]
477
478 for i in range(0, 13):
479 if (value & (1 << (i * 2))) != 0:
480 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
481 if (value & (1 << (i * 2 + 1))) != 0:
482 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
483
484 updates = self.manager.update_keyboard(keys)
485 if len(updates) > 0:
486 self.manager.game_ctx.send_update_keyboard(updates)
487
488 async def update_worldports(self, updates: set[int]):
489 await self.send_msgs([{
490 "cmd": "Set",
491 "key": self.get_datastorage_key("worldports"),
492 "want_reply": True,
493 "operations": [{
494 "operation": "update",
495 "value": updates
496 }]
497 }])
498
499 def handle_status_update(self, value: int):
500 self.manager.goaled = (value == ClientStatus.CLIENT_GOAL)
501 self.manager.tracker.refresh_state()
502 self.manager.game_ctx.send_accessible_locations()
503
504
505async def pipe_loop(manager: Lingo2Manager):
506 while not manager.client_ctx.exit_event.is_set():
507 try:
508 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
509 max_size=MESSAGE_MAX_SIZE)
510 manager.game_ctx.server = Endpoint(socket)
511 logger.info("Connected to Lingo 2!")
512 if manager.client_ctx.auth is not None:
513 manager.game_ctx.send_connected()
514 manager.game_ctx.send_accessible_locations()
515 async for data in manager.game_ctx.server.socket:
516 for msg in decode(data):
517 await process_game_cmd(manager, msg)
518 except ConnectionRefusedError:
519 logger.info("Could not connect to Lingo 2.")
520 finally:
521 manager.game_ctx.server = None
522
523
524async def process_game_cmd(manager: Lingo2Manager, args: dict):
525 cmd = args["cmd"]
526
527 if cmd == "Connect":
528 manager.client_ctx.seed_name = None
529
530 server = args.get("server")
531 player = args.get("player")
532 password = args.get("password")
533
534 if password != "":
535 server_address = f"{player}:{password}@{server}"
536 else:
537 server_address = f"{player}:None@{server}"
538
539 async_start(manager.client_ctx.connect(server_address), name="client connect")
540 elif cmd == "Disconnect":
541 manager.client_ctx.seed_name = None
542
543 async_start(manager.client_ctx.disconnect(), name="client disconnect")
544 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
545 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
546 elif cmd == "UpdateKeyboard":
547 updates = manager.update_keyboard(args["keyboard"])
548 if len(updates) > 0:
549 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
550 elif cmd == "CheckWorldport":
551 port_id = args["port_id"]
552 worldports = {port_id}
553 if str(port_id) in manager.client_ctx.slot_data["port_pairings"]:
554 worldports.add(manager.client_ctx.slot_data["port_pairings"][str(port_id)])
555
556 updates = manager.update_worldports(worldports)
557 if len(updates) > 0:
558 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
559 manager.game_ctx.send_update_worldports(updates)
560 elif cmd == "GetPath":
561 path = None
562
563 if args["type"] == "location":
564 path = manager.tracker.get_path_to_location(args["id"])
565 elif args["type"] == "worldport":
566 path = manager.tracker.get_path_to_port(args["id"])
567 elif args["type"] == "goal":
568 path = manager.tracker.get_path_to_goal()
569
570 manager.game_ctx.send_path_reply(args["type"], args.get("id", None), path)
571 elif cmd == "Quit":
572 manager.client_ctx.exit_event.set()
573
574
575async def run_game():
576 exe_file = settings.get_settings().lingo2_options.exe_file
577
578 # This ensures we can use Steam features without having to open the game
579 # through steam.
580 steam_appid_path = os.path.join(os.path.dirname(exe_file), "steam_appid.txt")
581 with open(steam_appid_path, "w") as said_handle:
582 said_handle.write("2523310")
583
584 if Lingo2World.zip_path is not None:
585 # This is a packaged apworld.
586 init_scene = pkgutil.get_data(__name__, "client/run_from_apworld.tscn")
587 init_path = Utils.local_path("data", "lingo2_init.tscn")
588
589 with open(init_path, "wb") as file_handle:
590 file_handle.write(init_scene)
591
592 subprocess.Popen(
593 [
594 exe_file,
595 "--scene",
596 init_path,
597 "--",
598 str(Lingo2World.zip_path.absolute()),
599 ],
600 cwd=os.path.dirname(exe_file),
601 )
602 else:
603 # The world is unzipped and being run in source.
604 subprocess.Popen(
605 [
606 exe_file,
607 "--scene",
608 Utils.local_path("worlds", "lingo2", "client", "run_from_source.tscn"),
609 "--",
610 Utils.local_path("worlds", "lingo2", "client"),
611 ],
612 cwd=os.path.dirname(exe_file),
613 )
614
615
616def client_main(*launch_args: str) -> None:
617 async def main(args):
618 if settings.get_settings().lingo2_options.start_game:
619 async_start(run_game())
620
621 client_ctx = Lingo2ClientContext(args.connect, args.password)
622 game_ctx = Lingo2GameContext()
623 manager = Lingo2Manager(game_ctx, client_ctx)
624
625 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
626
627 if gui_enabled:
628 client_ctx.run_gui()
629 client_ctx.run_cli()
630
631 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
632
633 try:
634 await pipe_task
635 except Exception as e:
636 logger.exception(e)
637
638 await client_ctx.exit_event.wait()
639 client_ctx.ui.stop()
640 await client_ctx.shutdown()
641
642 Utils.init_logging("Lingo2Client", exception_logger="Client")
643 import colorama
644
645 parser = get_base_parser(description="Lingo 2 Archipelago Client")
646 parser.add_argument('--name', default=None, help="Slot Name to connect as.")
647 parser.add_argument("url", nargs="?", help="Archipelago connection url")
648 args = parser.parse_args(launch_args)
649
650 args = handle_url_arg(args, parser=parser)
651
652 colorama.just_fix_windows_console()
653 asyncio.run(main(args))
654 colorama.deinit()