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.py630
1 files changed, 630 insertions, 0 deletions
diff --git a/apworld/context.py b/apworld/context.py new file mode 100644 index 0000000..63645a4 --- /dev/null +++ b/apworld/context.py
@@ -0,0 +1,630 @@
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 async def send_msgs(self, msgs: list[Any]) -> None:
233 """ `msgs` JSON serializable """
234 if not self.server or not self.server.socket.open or self.server.socket.closed:
235 return
236 await self.server.socket.send(encode(msgs))
237
238
239class Lingo2ClientContext(CommonContext):
240 manager: Lingo2Manager
241
242 game = "Lingo 2"
243 items_handling = 0b111
244
245 slot_data: dict[str, Any] | None
246 victory_data_storage_key: str
247
248 def __init__(self, server_address: str | None = None, password: str | None = None):
249 super().__init__(server_address, password)
250
251 def make_gui(self):
252 ui = super().make_gui()
253 ui.base_title = "Archipelago Lingo 2 Client"
254 return ui
255
256 async def server_auth(self, password_requested: bool = False):
257 if password_requested:
258 if self.password is None:
259 self.manager.game_ctx.send_connection_refused("Slot requires a password.")
260 else:
261 self.manager.game_ctx.send_connection_refused("Invalid password.")
262 else:
263 self.auth = self.username
264 await self.send_connect()
265
266 def handle_connection_loss(self, msg: str):
267 super().handle_connection_loss(msg)
268
269 exc_info = sys.exc_info()
270 self.manager.game_ctx.send_connection_refused(str(exc_info[1]))
271
272 def on_package(self, cmd: str, args: dict):
273 if cmd == "RoomInfo":
274 self.seed_name = args.get("seed_name", None)
275 elif cmd == "Connected":
276 self.slot_data = args.get("slot_data", None)
277
278 self.manager.reset()
279
280 self.manager.game_ctx.send_connected()
281
282 self.manager.tracker.setup_slot(self.slot_data)
283 self.manager.tracker.set_checked_locations(self.checked_locations)
284 self.manager.game_ctx.send_accessible_locations()
285
286 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}"
287
288 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"),
289 self.victory_data_storage_key)
290 msg_batch = [{
291 "cmd": "Set",
292 "key": self.get_datastorage_key("keyboard1"),
293 "default": 0,
294 "want_reply": True,
295 "operations": [{"operation": "default", "value": 0}]
296 }, {
297 "cmd": "Set",
298 "key": self.get_datastorage_key("keyboard2"),
299 "default": 0,
300 "want_reply": True,
301 "operations": [{"operation": "default", "value": 0}]
302 }]
303
304 if self.slot_data.get("shuffle_worldports", False):
305 self.set_notify(self.get_datastorage_key("worldports"))
306 msg_batch.append({
307 "cmd": "Set",
308 "key": self.get_datastorage_key("worldports"),
309 "default": [],
310 "want_reply": True,
311 "operations": [{"operation": "default", "value": []}]
312 })
313
314 async_start(self.send_msgs(msg_batch), name="default keys")
315 elif cmd == "RoomUpdate":
316 if "checked_locations" in args:
317 self.manager.tracker.set_checked_locations(self.checked_locations)
318 self.manager.game_ctx.send_update_locations(args["checked_locations"])
319 elif cmd == "ReceivedItems":
320 self.manager.tracker.set_collected_items(self.items_received)
321
322 cur_index = 0
323 items = []
324
325 for item in args["items"]:
326 index = cur_index + args["index"]
327 cur_index += 1
328
329 item_msg = {
330 "id": item.item,
331 "index": index,
332 "flags": item.flags,
333 "text": self.item_names.lookup_in_slot(item.item, self.slot),
334 }
335
336 if item.player != self.slot:
337 item_msg["sender"] = self.player_names.get(item.player)
338
339 items.append(item_msg)
340
341 self.manager.game_ctx.send_item_received(items)
342
343 if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]):
344 self.manager.game_ctx.send_accessible_locations()
345 elif cmd == "PrintJSON":
346 if "receiving" in args and "item" in args and args["item"].player == self.slot:
347 item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"])
348 location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player)
349 receiver_name = self.player_names.get(args["receiving"])
350
351 if args["type"] == "Hint" and not args.get("found", False):
352 self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags,
353 int(args["receiving"]) == self.slot)
354 elif args["receiving"] != self.slot:
355 self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags)
356
357 parts = []
358 for message_part in args["data"]:
359 if "type" not in message_part and "text" in message_part:
360 parts.append({"type": "text", "text": message_part["text"]})
361 elif message_part["type"] == "player_id":
362 parts.append({
363 "type": "player",
364 "text": self.player_names.get(int(message_part["text"])),
365 "self": int(int(message_part["text"]) == self.slot),
366 })
367 elif message_part["type"] == "item_id":
368 parts.append({
369 "type": "item",
370 "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]),
371 "flags": message_part["flags"],
372 })
373 elif message_part["type"] == "location_id":
374 parts.append({
375 "type": "location",
376 "text": self.location_names.lookup_in_slot(int(message_part["text"]),
377 message_part["player"])
378 })
379 elif "text" in message_part:
380 parts.append({"type": "text", "text": message_part["text"]})
381
382 self.manager.game_ctx.send_text_message(parts)
383 elif cmd == "LocationInfo":
384 locations = []
385
386 for location in args["locations"]:
387 locations.append({
388 "id": location.location,
389 "item": self.item_names.lookup_in_slot(location.item, location.player),
390 "player": self.player_names.get(location.player),
391 "flags": location.flags,
392 "self": int(location.player) == self.slot,
393 })
394
395 self.manager.game_ctx.send_location_info(locations)
396 elif cmd == "Retrieved":
397 for k, v in args["keys"].items():
398 if k == self.victory_data_storage_key:
399 self.handle_status_update(v)
400 elif cmd == "SetReply":
401 if args["key"] == self.get_datastorage_key("keyboard1"):
402 self.handle_keyboard_update(1, args)
403 elif args["key"] == self.get_datastorage_key("keyboard2"):
404 self.handle_keyboard_update(2, args)
405 elif args["key"] == self.get_datastorage_key("worldports"):
406 updates = self.manager.update_worldports(set(args["value"]))
407 if len(updates) > 0:
408 self.manager.game_ctx.send_update_worldports(updates)
409 elif args["key"] == self.victory_data_storage_key:
410 self.handle_status_update(args["value"])
411
412 def get_datastorage_key(self, name: str):
413 return f"Lingo2_{self.slot}_{name}"
414
415 async def update_keyboard(self, updates: dict[str, int]):
416 kb1 = 0
417 kb2 = 0
418
419 for k, v in updates.items():
420 if v == 0:
421 continue
422
423 effect = 0
424 if v >= 1:
425 effect |= 1
426 if v == 2:
427 effect |= 2
428
429 pos = KEY_STORAGE_MAPPING[k]
430 if pos[0] == 1:
431 kb1 |= (effect << pos[1] * 2)
432 else:
433 kb2 |= (effect << pos[1] * 2)
434
435 msgs = []
436
437 if kb1 != 0:
438 msgs.append({
439 "cmd": "Set",
440 "key": self.get_datastorage_key("keyboard1"),
441 "want_reply": True,
442 "operations": [{
443 "operation": "or",
444 "value": kb1
445 }]
446 })
447
448 if kb2 != 0:
449 msgs.append({
450 "cmd": "Set",
451 "key": self.get_datastorage_key("keyboard2"),
452 "want_reply": True,
453 "operations": [{
454 "operation": "or",
455 "value": kb2
456 }]
457 })
458
459 if len(msgs) > 0:
460 await self.send_msgs(msgs)
461
462 def handle_keyboard_update(self, field: int, args: dict[str, Any]):
463 keys = {}
464 value = args["value"]
465
466 for i in range(0, 13):
467 if (value & (1 << (i * 2))) != 0:
468 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1
469 if (value & (1 << (i * 2 + 1))) != 0:
470 keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2
471
472 updates = self.manager.update_keyboard(keys)
473 if len(updates) > 0:
474 self.manager.game_ctx.send_update_keyboard(updates)
475
476 async def update_worldports(self, updates: set[int]):
477 await self.send_msgs([{
478 "cmd": "Set",
479 "key": self.get_datastorage_key("worldports"),
480 "want_reply": True,
481 "operations": [{
482 "operation": "update",
483 "value": updates
484 }]
485 }])
486
487 def handle_status_update(self, value: int):
488 self.manager.goaled = (value == ClientStatus.CLIENT_GOAL)
489 self.manager.tracker.refresh_state()
490 self.manager.game_ctx.send_accessible_locations()
491
492
493async def pipe_loop(manager: Lingo2Manager):
494 while not manager.client_ctx.exit_event.is_set():
495 try:
496 socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None,
497 max_size=MESSAGE_MAX_SIZE)
498 manager.game_ctx.server = Endpoint(socket)
499 logger.info("Connected to Lingo 2!")
500 if manager.client_ctx.auth is not None:
501 manager.game_ctx.send_connected()
502 manager.game_ctx.send_accessible_locations()
503 async for data in manager.game_ctx.server.socket:
504 for msg in decode(data):
505 await process_game_cmd(manager, msg)
506 except ConnectionRefusedError:
507 logger.info("Could not connect to Lingo 2.")
508 finally:
509 manager.game_ctx.server = None
510
511
512async def process_game_cmd(manager: Lingo2Manager, args: dict):
513 cmd = args["cmd"]
514
515 if cmd == "Connect":
516 manager.client_ctx.seed_name = None
517
518 server = args.get("server")
519 player = args.get("player")
520 password = args.get("password")
521
522 if password != "":
523 server_address = f"{player}:{password}@{server}"
524 else:
525 server_address = f"{player}:None@{server}"
526
527 async_start(manager.client_ctx.connect(server_address), name="client connect")
528 elif cmd == "Disconnect":
529 manager.client_ctx.seed_name = None
530
531 async_start(manager.client_ctx.disconnect(), name="client disconnect")
532 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
533 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
534 elif cmd == "UpdateKeyboard":
535 updates = manager.update_keyboard(args["keyboard"])
536 if len(updates) > 0:
537 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
538 elif cmd == "CheckWorldport":
539 port_id = args["port_id"]
540 worldports = {port_id}
541 if str(port_id) in manager.client_ctx.slot_data["port_pairings"]:
542 worldports.add(manager.client_ctx.slot_data["port_pairings"][str(port_id)])
543
544 updates = manager.update_worldports(worldports)
545 if len(updates) > 0:
546 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
547 manager.game_ctx.send_update_worldports(updates)
548 elif cmd == "Quit":
549 manager.client_ctx.exit_event.set()
550
551
552async def run_game():
553 exe_file = settings.get_settings().lingo2_options.exe_file
554
555 # This ensures we can use Steam features without having to open the game
556 # through steam.
557 steam_appid_path = os.path.join(os.path.dirname(exe_file), "steam_appid.txt")
558 with open(steam_appid_path, "w") as said_handle:
559 said_handle.write("2523310")
560
561 if Lingo2World.zip_path is not None:
562 # This is a packaged apworld.
563 init_scene = pkgutil.get_data(__name__, "client/run_from_apworld.tscn")
564 init_path = Utils.local_path("data", "lingo2_init.tscn")
565
566 with open(init_path, "wb") as file_handle:
567 file_handle.write(init_scene)
568
569 subprocess.Popen(
570 [
571 exe_file,
572 "--scene",
573 init_path,
574 "--",
575 str(Lingo2World.zip_path.absolute()),
576 ],
577 cwd=os.path.dirname(exe_file),
578 )
579 else:
580 # The world is unzipped and being run in source.
581 subprocess.Popen(
582 [
583 exe_file,
584 "--scene",
585 Utils.local_path("worlds", "lingo2", "client", "run_from_source.tscn"),
586 "--",
587 Utils.local_path("worlds", "lingo2", "client"),
588 ],
589 cwd=os.path.dirname(exe_file),
590 )
591
592
593def client_main(*launch_args: str) -> None:
594 async def main(args):
595 async_start(run_game())
596
597 client_ctx = Lingo2ClientContext(args.connect, args.password)
598 game_ctx = Lingo2GameContext()
599 manager = Lingo2Manager(game_ctx, client_ctx)
600
601 client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop")
602
603 if gui_enabled:
604 client_ctx.run_gui()
605 client_ctx.run_cli()
606
607 pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher")
608
609 try:
610 await pipe_task
611 except Exception as e:
612 logger.exception(e)
613
614 await client_ctx.exit_event.wait()
615 client_ctx.ui.stop()
616 await client_ctx.shutdown()
617
618 Utils.init_logging("Lingo2Client", exception_logger="Client")
619 import colorama
620
621 parser = get_base_parser(description="Lingo 2 Archipelago Client")
622 parser.add_argument('--name', default=None, help="Slot Name to connect as.")
623 parser.add_argument("url", nargs="?", help="Archipelago connection url")
624 args = parser.parse_args(launch_args)
625
626 args = handle_url_arg(args, parser=parser)
627
628 colorama.just_fix_windows_console()
629 asyncio.run(main(args))
630 colorama.deinit()