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.py216
1 files changed, 207 insertions, 9 deletions
diff --git a/apworld/context.py b/apworld/context.py index c367b6c..86392f9 100644 --- a/apworld/context.py +++ b/apworld/context.py
@@ -2,6 +2,7 @@ import asyncio
2import os 2import os
3import pkgutil 3import pkgutil
4import subprocess 4import subprocess
5import sys
5from typing import Any 6from typing import Any
6 7
7import websockets 8import websockets
@@ -29,6 +30,16 @@ KEY_STORAGE_MAPPING = {
29REVERSE_KEY_STORAGE_MAPPING = {t: k for k, t in KEY_STORAGE_MAPPING.items()} 30REVERSE_KEY_STORAGE_MAPPING = {t: k for k, t in KEY_STORAGE_MAPPING.items()}
30 31
31 32
33# There is a distinction between an object's ID and its AP ID. The latter is stable between releases, whereas the former
34# can change and is also namespaced based on the object type. We should only store AP IDs in multiworld state (such as
35# slot data and data storage) to increase compatability between releases. The data we currently store is:
36# - Port pairings for worldport shuffle (slot data)
37# - Checked worldports for worldport shuffle (data storage)
38# - Latched doors (data storage)
39# The client generally deals in the actual object IDs rather than the stable IDs, although it does have to convert the
40# port pairing IDs when reading them from slot data. The context (this file here) does the work of converting back and
41# forth between the values. AP IDs are converted to IDs after reading them from data storage, and IDs are converted to
42# AP IDs before sending them to data storage.
32class Lingo2Manager: 43class Lingo2Manager:
33 game_ctx: "Lingo2GameContext" 44 game_ctx: "Lingo2GameContext"
34 client_ctx: "Lingo2ClientContext" 45 client_ctx: "Lingo2ClientContext"
@@ -37,6 +48,8 @@ class Lingo2Manager:
37 keyboard: dict[str, int] 48 keyboard: dict[str, int]
38 worldports: set[int] 49 worldports: set[int]
39 goaled: bool 50 goaled: bool
51 latches: set[int]
52 hinted_locations: set[int]
40 53
41 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"): 54 def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"):
42 self.game_ctx = game_ctx 55 self.game_ctx = game_ctx
@@ -45,7 +58,6 @@ class Lingo2Manager:
45 self.client_ctx.manager = self 58 self.client_ctx.manager = self
46 self.tracker = Tracker(self) 59 self.tracker = Tracker(self)
47 self.keyboard = {} 60 self.keyboard = {}
48 self.worldports = set()
49 61
50 self.reset() 62 self.reset()
51 63
@@ -55,6 +67,8 @@ class Lingo2Manager:
55 67
56 self.worldports = set() 68 self.worldports = set()
57 self.goaled = False 69 self.goaled = False
70 self.latches = set()
71 self.hinted_locations = set()
58 72
59 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]: 73 def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]:
60 ret: dict[str, int] = {} 74 ret: dict[str, int] = {}
@@ -70,6 +84,7 @@ class Lingo2Manager:
70 84
71 return ret 85 return ret
72 86
87 # Input should be real IDs, not AP IDs
73 def update_worldports(self, new_worldports: set[int]) -> set[int]: 88 def update_worldports(self, new_worldports: set[int]) -> set[int]:
74 ret = new_worldports.difference(self.worldports) 89 ret = new_worldports.difference(self.worldports)
75 self.worldports.update(new_worldports) 90 self.worldports.update(new_worldports)
@@ -80,6 +95,18 @@ class Lingo2Manager:
80 95
81 return ret 96 return ret
82 97
98 def update_latches(self, new_latches: set[int]) -> set[int]:
99 ret = new_latches.difference(self.latches)
100 self.latches.update(new_latches)
101
102 return ret
103
104 def update_hinted_locations(self, new_locs: set[int]) -> set[int]:
105 ret = new_locs.difference(self.hinted_locations)
106 self.hinted_locations.update(new_locs)
107
108 return ret
109
83 110
84class Lingo2GameContext: 111class Lingo2GameContext:
85 server: Endpoint | None 112 server: Endpoint | None
@@ -106,6 +133,17 @@ class Lingo2GameContext:
106 133
107 async_start(self.send_msgs([msg]), name="game Connected") 134 async_start(self.send_msgs([msg]), name="game Connected")
108 135
136 def send_connection_refused(self, text):
137 if self.server is None:
138 return
139
140 msg = {
141 "cmd": "ConnectionRefused",
142 "text": text,
143 }
144
145 async_start(self.send_msgs([msg]), name="game ConnectionRefused")
146
109 def send_item_sent_notification(self, item_name, receiver_name, item_flags): 147 def send_item_sent_notification(self, item_name, receiver_name, item_flags):
110 if self.server is None: 148 if self.server is None:
111 return 149 return
@@ -206,6 +244,7 @@ class Lingo2GameContext:
206 244
207 async_start(self.send_msgs([msg]), name="update keyboard") 245 async_start(self.send_msgs([msg]), name="update keyboard")
208 246
247 # Input should be real IDs, not AP IDs
209 def send_update_worldports(self, worldports): 248 def send_update_worldports(self, worldports):
210 if self.server is None: 249 if self.server is None:
211 return 250 return
@@ -217,6 +256,54 @@ class Lingo2GameContext:
217 256
218 async_start(self.send_msgs([msg]), name="update worldports") 257 async_start(self.send_msgs([msg]), name="update worldports")
219 258
259 def send_path_reply(self, object_type: str, object_id: int | None, path: list[str]):
260 if self.server is None:
261 return
262
263 msg = {
264 "cmd": "PathReply",
265 "type": object_type,
266 "path": path,
267 }
268
269 if object_id is not None:
270 msg["id"] = object_id
271
272 async_start(self.send_msgs([msg]), name="path reply")
273
274 def send_update_latches(self, latches):
275 if self.server is None:
276 return
277
278 msg = {
279 "cmd": "UpdateLatches",
280 "latches": latches,
281 }
282
283 async_start(self.send_msgs([msg]), name="update latches")
284
285 def send_ignored_locations(self, ignored_locations):
286 if self.server is None:
287 return
288
289 msg = {
290 "cmd": "SetIgnoredLocations",
291 "locations": ignored_locations,
292 }
293
294 async_start(self.send_msgs([msg]), name="set ignored locations")
295
296 def send_update_hinted_locations(self, hinted_locations):
297 if self.server is None:
298 return
299
300 msg = {
301 "cmd": "UpdateHintedLocations",
302 "locations": hinted_locations,
303 }
304
305 async_start(self.send_msgs([msg]), name="update hinted locations")
306
220 async def send_msgs(self, msgs: list[Any]) -> None: 307 async def send_msgs(self, msgs: list[Any]) -> None:
221 """ `msgs` JSON serializable """ 308 """ `msgs` JSON serializable """
222 if not self.server or not self.server.socket.open or self.server.socket.closed: 309 if not self.server or not self.server.socket.open or self.server.socket.closed:
@@ -231,6 +318,7 @@ class Lingo2ClientContext(CommonContext):
231 items_handling = 0b111 318 items_handling = 0b111
232 319
233 slot_data: dict[str, Any] | None 320 slot_data: dict[str, Any] | None
321 hints_data_storage_key: str
234 victory_data_storage_key: str 322 victory_data_storage_key: str
235 323
236 def __init__(self, server_address: str | None = None, password: str | None = None): 324 def __init__(self, server_address: str | None = None, password: str | None = None):
@@ -242,8 +330,17 @@ class Lingo2ClientContext(CommonContext):
242 return ui 330 return ui
243 331
244 async def server_auth(self, password_requested: bool = False): 332 async def server_auth(self, password_requested: bool = False):
245 self.auth = self.username 333 if password_requested and not self.password:
246 await self.send_connect() 334 self.manager.game_ctx.send_connection_refused("Invalid password.")
335 else:
336 self.auth = self.username
337 await self.send_connect()
338
339 def handle_connection_loss(self, msg: str):
340 super().handle_connection_loss(msg)
341
342 exc_info = sys.exc_info()
343 self.manager.game_ctx.send_connection_refused(str(exc_info[1]))
247 344
248 def on_package(self, cmd: str, args: dict): 345 def on_package(self, cmd: str, args: dict):
249 if cmd == "RoomInfo": 346 if cmd == "RoomInfo":
@@ -259,10 +356,12 @@ class Lingo2ClientContext(CommonContext):
259 self.manager.tracker.set_checked_locations(self.checked_locations) 356 self.manager.tracker.set_checked_locations(self.checked_locations)
260 self.manager.game_ctx.send_accessible_locations() 357 self.manager.game_ctx.send_accessible_locations()
261 358
359 self.hints_data_storage_key = f"_read_hints_{self.team}_{self.slot}"
262 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}" 360 self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}"
263 361
264 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"), 362 self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"),
265 self.victory_data_storage_key) 363 self.victory_data_storage_key, self.get_datastorage_key("latches"),
364 self.get_datastorage_key("ignored_locations"))
266 msg_batch = [{ 365 msg_batch = [{
267 "cmd": "Set", 366 "cmd": "Set",
268 "key": self.get_datastorage_key("keyboard1"), 367 "key": self.get_datastorage_key("keyboard1"),
@@ -275,6 +374,18 @@ class Lingo2ClientContext(CommonContext):
275 "default": 0, 374 "default": 0,
276 "want_reply": True, 375 "want_reply": True,
277 "operations": [{"operation": "default", "value": 0}] 376 "operations": [{"operation": "default", "value": 0}]
377 }, {
378 "cmd": "Set",
379 "key": self.get_datastorage_key("latches"),
380 "default": [],
381 "want_reply": True,
382 "operations": [{"operation": "default", "value": []}]
383 }, {
384 "cmd": "Set",
385 "key": self.get_datastorage_key("ignored_locations"),
386 "default": [],
387 "want_reply": True,
388 "operations": [{"operation": "default", "value": []}]
278 }] 389 }]
279 390
280 if self.slot_data.get("shuffle_worldports", False): 391 if self.slot_data.get("shuffle_worldports", False):
@@ -373,17 +484,29 @@ class Lingo2ClientContext(CommonContext):
373 for k, v in args["keys"].items(): 484 for k, v in args["keys"].items():
374 if k == self.victory_data_storage_key: 485 if k == self.victory_data_storage_key:
375 self.handle_status_update(v) 486 self.handle_status_update(v)
487 elif k == self.hints_data_storage_key:
488 self.update_hints()
376 elif cmd == "SetReply": 489 elif cmd == "SetReply":
377 if args["key"] == self.get_datastorage_key("keyboard1"): 490 if args["key"] == self.get_datastorage_key("keyboard1"):
378 self.handle_keyboard_update(1, args) 491 self.handle_keyboard_update(1, args)
379 elif args["key"] == self.get_datastorage_key("keyboard2"): 492 elif args["key"] == self.get_datastorage_key("keyboard2"):
380 self.handle_keyboard_update(2, args) 493 self.handle_keyboard_update(2, args)
381 elif args["key"] == self.get_datastorage_key("worldports"): 494 elif args["key"] == self.get_datastorage_key("worldports"):
382 updates = self.manager.update_worldports(set(args["value"])) 495 port_ids = set(Lingo2World.static_logic.port_id_by_ap_id[ap_id] for ap_id in args["value"])
496 updates = self.manager.update_worldports(port_ids)
383 if len(updates) > 0: 497 if len(updates) > 0:
384 self.manager.game_ctx.send_update_worldports(updates) 498 self.manager.game_ctx.send_update_worldports(updates)
385 elif args["key"] == self.victory_data_storage_key: 499 elif args["key"] == self.victory_data_storage_key:
386 self.handle_status_update(args["value"]) 500 self.handle_status_update(args["value"])
501 elif args["key"] == self.get_datastorage_key("latches"):
502 door_ids = set(Lingo2World.static_logic.door_id_by_ap_id[ap_id] for ap_id in args["value"])
503 updates = self.manager.update_latches(door_ids)
504 if len(updates) > 0:
505 self.manager.game_ctx.send_update_latches(updates)
506 elif args["key"] == self.get_datastorage_key("ignored_locations"):
507 self.manager.game_ctx.send_ignored_locations(args["value"])
508 elif args["key"] == self.hints_data_storage_key:
509 self.update_hints()
387 510
388 def get_datastorage_key(self, name: str): 511 def get_datastorage_key(self, name: str):
389 return f"Lingo2_{self.slot}_{name}" 512 return f"Lingo2_{self.slot}_{name}"
@@ -449,14 +572,16 @@ class Lingo2ClientContext(CommonContext):
449 if len(updates) > 0: 572 if len(updates) > 0:
450 self.manager.game_ctx.send_update_keyboard(updates) 573 self.manager.game_ctx.send_update_keyboard(updates)
451 574
575 # Input should be real IDs, not AP IDs
452 async def update_worldports(self, updates: set[int]): 576 async def update_worldports(self, updates: set[int]):
577 port_ap_ids = [Lingo2World.static_logic.objects.ports[port_id].ap_id for port_id in updates]
453 await self.send_msgs([{ 578 await self.send_msgs([{
454 "cmd": "Set", 579 "cmd": "Set",
455 "key": self.get_datastorage_key("worldports"), 580 "key": self.get_datastorage_key("worldports"),
456 "want_reply": True, 581 "want_reply": True,
457 "operations": [{ 582 "operations": [{
458 "operation": "update", 583 "operation": "update",
459 "value": updates 584 "value": port_ap_ids
460 }] 585 }]
461 }]) 586 }])
462 587
@@ -465,6 +590,48 @@ class Lingo2ClientContext(CommonContext):
465 self.manager.tracker.refresh_state() 590 self.manager.tracker.refresh_state()
466 self.manager.game_ctx.send_accessible_locations() 591 self.manager.game_ctx.send_accessible_locations()
467 592
593 async def update_latches(self, updates: set[int]):
594 door_ap_ids = [Lingo2World.static_logic.objects.doors[door_id].ap_id for door_id in updates]
595 await self.send_msgs([{
596 "cmd": "Set",
597 "key": self.get_datastorage_key("latches"),
598 "want_reply": True,
599 "operations": [{
600 "operation": "update",
601 "value": door_ap_ids
602 }]
603 }])
604
605 async def add_ignored_location(self, loc_id: int):
606 await self.send_msgs([{
607 "cmd": "Set",
608 "key": self.get_datastorage_key("ignored_locations"),
609 "want_reply": True,
610 "operations": [{
611 "operation": "update",
612 "value": [loc_id]
613 }]
614 }])
615
616 async def remove_ignored_location(self, loc_id: int):
617 await self.send_msgs([{
618 "cmd": "Set",
619 "key": self.get_datastorage_key("ignored_locations"),
620 "want_reply": True,
621 "operations": [{
622 "operation": "remove",
623 "value": loc_id
624 }]
625 }])
626
627 def update_hints(self):
628 hints = self.stored_data.get(self.hints_data_storage_key, [])
629
630 hinted_locations = set(hint["location"] for hint in hints if hint["finding_player"] == self.slot)
631 updates = self.manager.update_hinted_locations(hinted_locations)
632 if len(updates) > 0:
633 self.manager.game_ctx.send_update_hinted_locations(updates)
634
468 635
469async def pipe_loop(manager: Lingo2Manager): 636async def pipe_loop(manager: Lingo2Manager):
470 while not manager.client_ctx.exit_event.is_set(): 637 while not manager.client_ctx.exit_event.is_set():
@@ -489,6 +656,8 @@ async def process_game_cmd(manager: Lingo2Manager, args: dict):
489 cmd = args["cmd"] 656 cmd = args["cmd"]
490 657
491 if cmd == "Connect": 658 if cmd == "Connect":
659 manager.client_ctx.seed_name = None
660
492 server = args.get("server") 661 server = args.get("server")
493 player = args.get("player") 662 player = args.get("player")
494 password = args.get("password") 663 password = args.get("password")
@@ -500,6 +669,8 @@ async def process_game_cmd(manager: Lingo2Manager, args: dict):
500 669
501 async_start(manager.client_ctx.connect(server_address), name="client connect") 670 async_start(manager.client_ctx.connect(server_address), name="client connect")
502 elif cmd == "Disconnect": 671 elif cmd == "Disconnect":
672 manager.client_ctx.seed_name = None
673
503 async_start(manager.client_ctx.disconnect(), name="client disconnect") 674 async_start(manager.client_ctx.disconnect(), name="client disconnect")
504 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]: 675 elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]:
505 async_start(manager.client_ctx.send_msgs([args]), name="client forward") 676 async_start(manager.client_ctx.send_msgs([args]), name="client forward")
@@ -509,14 +680,38 @@ async def process_game_cmd(manager: Lingo2Manager, args: dict):
509 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard") 680 async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard")
510 elif cmd == "CheckWorldport": 681 elif cmd == "CheckWorldport":
511 port_id = args["port_id"] 682 port_id = args["port_id"]
683 port_ap_id = Lingo2World.static_logic.objects.ports[port_id].ap_id
512 worldports = {port_id} 684 worldports = {port_id}
513 if str(port_id) in manager.client_ctx.slot_data["port_pairings"]: 685
514 worldports.add(manager.client_ctx.slot_data["port_pairings"][str(port_id)]) 686 # Also check the reverse port if it's a two-way connection.
687 port_pairings = manager.client_ctx.slot_data["port_pairings"]
688 if str(port_ap_id) in port_pairings and\
689 port_pairings.get(str(port_pairings[str(port_ap_id)]), None) == port_ap_id:
690 worldports.add(Lingo2World.static_logic.port_id_by_ap_id[port_pairings[str(port_ap_id)]])
515 691
516 updates = manager.update_worldports(worldports) 692 updates = manager.update_worldports(worldports)
517 if len(updates) > 0: 693 if len(updates) > 0:
518 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports") 694 async_start(manager.client_ctx.update_worldports(updates), name="client update worldports")
519 manager.game_ctx.send_update_worldports(updates) 695 manager.game_ctx.send_update_worldports(updates)
696 elif cmd == "GetPath":
697 path = None
698
699 if args["type"] == "location":
700 path = manager.tracker.get_path_to_location(args["id"])
701 elif args["type"] == "worldport":
702 path = manager.tracker.get_path_to_port(args["id"])
703 elif args["type"] == "goal":
704 path = manager.tracker.get_path_to_goal()
705
706 manager.game_ctx.send_path_reply(args["type"], args.get("id", None), path)
707 elif cmd == "LatchDoor":
708 updates = manager.update_latches({args["door"]})
709 if len(updates) > 0:
710 async_start(manager.client_ctx.update_latches(updates), name="client update latches")
711 elif cmd == "IgnoreLocation":
712 async_start(manager.client_ctx.add_ignored_location(args["id"]), name="client ignore loc")
713 elif cmd == "UnignoreLocation":
714 async_start(manager.client_ctx.remove_ignored_location(args["id"]), name="client unignore loc")
520 elif cmd == "Quit": 715 elif cmd == "Quit":
521 manager.client_ctx.exit_event.set() 716 manager.client_ctx.exit_event.set()
522 717
@@ -564,9 +759,12 @@ async def run_game():
564 759
565def client_main(*launch_args: str) -> None: 760def client_main(*launch_args: str) -> None:
566 async def main(args): 761 async def main(args):
567 async_start(run_game()) 762 if settings.get_settings().lingo2_options.start_game:
763 async_start(run_game())
568 764
569 client_ctx = Lingo2ClientContext(args.connect, args.password) 765 client_ctx = Lingo2ClientContext(args.connect, args.password)
766 client_ctx.auth = args.name
767
570 game_ctx = Lingo2GameContext() 768 game_ctx = Lingo2GameContext()
571 manager = Lingo2Manager(game_ctx, client_ctx) 769 manager = Lingo2Manager(game_ctx, client_ctx)
572 770