diff options
Diffstat (limited to 'apworld/context.py')
| -rw-r--r-- | apworld/context.py | 800 |
1 files changed, 800 insertions, 0 deletions
| diff --git a/apworld/context.py b/apworld/context.py new file mode 100644 index 0000000..86392f9 --- /dev/null +++ b/apworld/context.py | |||
| @@ -0,0 +1,800 @@ | |||
| 1 | import asyncio | ||
| 2 | import os | ||
| 3 | import pkgutil | ||
| 4 | import subprocess | ||
| 5 | import sys | ||
| 6 | from typing import Any | ||
| 7 | |||
| 8 | import websockets | ||
| 9 | |||
| 10 | import Utils | ||
| 11 | import settings | ||
| 12 | from BaseClasses import ItemClassification | ||
| 13 | from CommonClient import CommonContext, server_loop, gui_enabled, logger, get_base_parser, handle_url_arg | ||
| 14 | from NetUtils import Endpoint, decode, encode, ClientStatus | ||
| 15 | from Utils import async_start | ||
| 16 | from . import Lingo2World | ||
| 17 | from .tracker import Tracker | ||
| 18 | |||
| 19 | ALL_LETTERS = "abcdefghijklmnopqrstuvwxyz" | ||
| 20 | MESSAGE_MAX_SIZE = 16*1024*1024 | ||
| 21 | PORT = 43182 | ||
| 22 | |||
| 23 | KEY_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 | |||
| 30 | REVERSE_KEY_STORAGE_MAPPING = {t: k for k, t in KEY_STORAGE_MAPPING.items()} | ||
| 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. | ||
| 43 | class Lingo2Manager: | ||
| 44 | game_ctx: "Lingo2GameContext" | ||
| 45 | client_ctx: "Lingo2ClientContext" | ||
| 46 | tracker: Tracker | ||
| 47 | |||
| 48 | keyboard: dict[str, int] | ||
| 49 | worldports: set[int] | ||
| 50 | goaled: bool | ||
| 51 | latches: set[int] | ||
| 52 | hinted_locations: set[int] | ||
| 53 | |||
| 54 | def __init__(self, game_ctx: "Lingo2GameContext", client_ctx: "Lingo2ClientContext"): | ||
| 55 | self.game_ctx = game_ctx | ||
| 56 | self.game_ctx.manager = self | ||
| 57 | self.client_ctx = client_ctx | ||
| 58 | self.client_ctx.manager = self | ||
| 59 | self.tracker = Tracker(self) | ||
| 60 | self.keyboard = {} | ||
| 61 | |||
| 62 | self.reset() | ||
| 63 | |||
| 64 | def reset(self): | ||
| 65 | for k in ALL_LETTERS: | ||
| 66 | self.keyboard[k] = 0 | ||
| 67 | |||
| 68 | self.worldports = set() | ||
| 69 | self.goaled = False | ||
| 70 | self.latches = set() | ||
| 71 | self.hinted_locations = set() | ||
| 72 | |||
| 73 | def update_keyboard(self, new_keyboard: dict[str, int]) -> dict[str, int]: | ||
| 74 | ret: dict[str, int] = {} | ||
| 75 | |||
| 76 | for k, v in new_keyboard.items(): | ||
| 77 | if v > self.keyboard.get(k, 0): | ||
| 78 | self.keyboard[k] = v | ||
| 79 | ret[k] = v | ||
| 80 | |||
| 81 | if len(ret) > 0: | ||
| 82 | self.tracker.refresh_state() | ||
| 83 | self.game_ctx.send_accessible_locations() | ||
| 84 | |||
| 85 | return ret | ||
| 86 | |||
| 87 | # Input should be real IDs, not AP IDs | ||
| 88 | def update_worldports(self, new_worldports: set[int]) -> set[int]: | ||
| 89 | ret = new_worldports.difference(self.worldports) | ||
| 90 | self.worldports.update(new_worldports) | ||
| 91 | |||
| 92 | if len(ret) > 0: | ||
| 93 | self.tracker.refresh_state() | ||
| 94 | self.game_ctx.send_accessible_locations() | ||
| 95 | |||
| 96 | return ret | ||
| 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 | |||
| 110 | |||
| 111 | class Lingo2GameContext: | ||
| 112 | server: Endpoint | None | ||
| 113 | manager: Lingo2Manager | ||
| 114 | |||
| 115 | def __init__(self): | ||
| 116 | self.server = None | ||
| 117 | |||
| 118 | def send_connected(self): | ||
| 119 | if self.server is None: | ||
| 120 | return | ||
| 121 | |||
| 122 | msg = { | ||
| 123 | "cmd": "Connected", | ||
| 124 | "user": self.manager.client_ctx.username, | ||
| 125 | "seed_name": self.manager.client_ctx.seed_name, | ||
| 126 | "version": self.manager.client_ctx.server_version, | ||
| 127 | "generator_version": self.manager.client_ctx.generator_version, | ||
| 128 | "team": self.manager.client_ctx.team, | ||
| 129 | "slot": self.manager.client_ctx.slot, | ||
| 130 | "checked_locations": self.manager.client_ctx.checked_locations, | ||
| 131 | "slot_data": self.manager.client_ctx.slot_data, | ||
| 132 | } | ||
| 133 | |||
| 134 | async_start(self.send_msgs([msg]), name="game Connected") | ||
| 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 | |||
| 147 | def send_item_sent_notification(self, item_name, receiver_name, item_flags): | ||
| 148 | if self.server is None: | ||
| 149 | return | ||
| 150 | |||
| 151 | msg = { | ||
| 152 | "cmd": "ItemSentNotif", | ||
| 153 | "item_name": item_name, | ||
| 154 | "receiver_name": receiver_name, | ||
| 155 | "item_flags": item_flags, | ||
| 156 | } | ||
| 157 | |||
| 158 | async_start(self.send_msgs([msg]), name="item sent notif") | ||
| 159 | |||
| 160 | def send_hint_received(self, item_name, location_name, receiver_name, item_flags, for_self): | ||
| 161 | if self.server is None: | ||
| 162 | return | ||
| 163 | |||
| 164 | msg = { | ||
| 165 | "cmd": "HintReceived", | ||
| 166 | "item_name": item_name, | ||
| 167 | "location_name": location_name, | ||
| 168 | "receiver_name": receiver_name, | ||
| 169 | "item_flags": item_flags, | ||
| 170 | "self": int(for_self), | ||
| 171 | } | ||
| 172 | |||
| 173 | async_start(self.send_msgs([msg]), name="hint received notif") | ||
| 174 | |||
| 175 | def send_item_received(self, items): | ||
| 176 | if self.server is None: | ||
| 177 | return | ||
| 178 | |||
| 179 | msg = { | ||
| 180 | "cmd": "ItemReceived", | ||
| 181 | "items": items, | ||
| 182 | } | ||
| 183 | |||
| 184 | async_start(self.send_msgs([msg]), name="item received") | ||
| 185 | |||
| 186 | def send_location_info(self, locations): | ||
| 187 | if self.server is None: | ||
| 188 | return | ||
| 189 | |||
| 190 | msg = { | ||
| 191 | "cmd": "LocationInfo", | ||
| 192 | "locations": locations, | ||
| 193 | } | ||
| 194 | |||
| 195 | async_start(self.send_msgs([msg]), name="location info") | ||
| 196 | |||
| 197 | def send_text_message(self, parts): | ||
| 198 | if self.server is None: | ||
| 199 | return | ||
| 200 | |||
| 201 | msg = { | ||
| 202 | "cmd": "TextMessage", | ||
| 203 | "data": parts, | ||
| 204 | } | ||
| 205 | |||
| 206 | async_start(self.send_msgs([msg]), name="notif") | ||
| 207 | |||
| 208 | def send_accessible_locations(self): | ||
| 209 | if self.server is None: | ||
| 210 | return | ||
| 211 | |||
| 212 | msg = { | ||
| 213 | "cmd": "AccessibleLocations", | ||
| 214 | "locations": list(self.manager.tracker.accessible_locations), | ||
| 215 | } | ||
| 216 | |||
| 217 | if len(self.manager.tracker.accessible_worldports) > 0: | ||
| 218 | msg["worldports"] = list(self.manager.tracker.accessible_worldports) | ||
| 219 | |||
| 220 | if self.manager.tracker.goal_accessible and not self.manager.goaled: | ||
| 221 | msg["goal"] = True | ||
| 222 | |||
| 223 | async_start(self.send_msgs([msg]), name="accessible locations") | ||
| 224 | |||
| 225 | def send_update_locations(self, locations): | ||
| 226 | if self.server is None: | ||
| 227 | return | ||
| 228 | |||
| 229 | msg = { | ||
| 230 | "cmd": "UpdateLocations", | ||
| 231 | "locations": locations, | ||
| 232 | } | ||
| 233 | |||
| 234 | async_start(self.send_msgs([msg]), name="update locations") | ||
| 235 | |||
| 236 | def send_update_keyboard(self, updates): | ||
| 237 | if self.server is None: | ||
| 238 | return | ||
| 239 | |||
| 240 | msg = { | ||
| 241 | "cmd": "UpdateKeyboard", | ||
| 242 | "updates": updates, | ||
| 243 | } | ||
| 244 | |||
| 245 | async_start(self.send_msgs([msg]), name="update keyboard") | ||
| 246 | |||
| 247 | # Input should be real IDs, not AP IDs | ||
| 248 | def send_update_worldports(self, worldports): | ||
| 249 | if self.server is None: | ||
| 250 | return | ||
| 251 | |||
| 252 | msg = { | ||
| 253 | "cmd": "UpdateWorldports", | ||
| 254 | "worldports": worldports, | ||
| 255 | } | ||
| 256 | |||
| 257 | async_start(self.send_msgs([msg]), name="update worldports") | ||
| 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 | |||
| 307 | async def send_msgs(self, msgs: list[Any]) -> None: | ||
| 308 | """ `msgs` JSON serializable """ | ||
| 309 | if not self.server or not self.server.socket.open or self.server.socket.closed: | ||
| 310 | return | ||
| 311 | await self.server.socket.send(encode(msgs)) | ||
| 312 | |||
| 313 | |||
| 314 | class Lingo2ClientContext(CommonContext): | ||
| 315 | manager: Lingo2Manager | ||
| 316 | |||
| 317 | game = "Lingo 2" | ||
| 318 | items_handling = 0b111 | ||
| 319 | |||
| 320 | slot_data: dict[str, Any] | None | ||
| 321 | hints_data_storage_key: str | ||
| 322 | victory_data_storage_key: str | ||
| 323 | |||
| 324 | def __init__(self, server_address: str | None = None, password: str | None = None): | ||
| 325 | super().__init__(server_address, password) | ||
| 326 | |||
| 327 | def make_gui(self): | ||
| 328 | ui = super().make_gui() | ||
| 329 | ui.base_title = "Archipelago Lingo 2 Client" | ||
| 330 | return ui | ||
| 331 | |||
| 332 | async def server_auth(self, password_requested: bool = False): | ||
| 333 | if password_requested and not self.password: | ||
| 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])) | ||
| 344 | |||
| 345 | def on_package(self, cmd: str, args: dict): | ||
| 346 | if cmd == "RoomInfo": | ||
| 347 | self.seed_name = args.get("seed_name", None) | ||
| 348 | elif cmd == "Connected": | ||
| 349 | self.slot_data = args.get("slot_data", None) | ||
| 350 | |||
| 351 | self.manager.reset() | ||
| 352 | |||
| 353 | self.manager.game_ctx.send_connected() | ||
| 354 | |||
| 355 | self.manager.tracker.setup_slot(self.slot_data) | ||
| 356 | self.manager.tracker.set_checked_locations(self.checked_locations) | ||
| 357 | self.manager.game_ctx.send_accessible_locations() | ||
| 358 | |||
| 359 | self.hints_data_storage_key = f"_read_hints_{self.team}_{self.slot}" | ||
| 360 | self.victory_data_storage_key = f"_read_client_status_{self.team}_{self.slot}" | ||
| 361 | |||
| 362 | self.set_notify(self.get_datastorage_key("keyboard1"), self.get_datastorage_key("keyboard2"), | ||
| 363 | self.victory_data_storage_key, self.get_datastorage_key("latches"), | ||
| 364 | self.get_datastorage_key("ignored_locations")) | ||
| 365 | msg_batch = [{ | ||
| 366 | "cmd": "Set", | ||
| 367 | "key": self.get_datastorage_key("keyboard1"), | ||
| 368 | "default": 0, | ||
| 369 | "want_reply": True, | ||
| 370 | "operations": [{"operation": "default", "value": 0}] | ||
| 371 | }, { | ||
| 372 | "cmd": "Set", | ||
| 373 | "key": self.get_datastorage_key("keyboard2"), | ||
| 374 | "default": 0, | ||
| 375 | "want_reply": True, | ||
| 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": []}] | ||
| 389 | }] | ||
| 390 | |||
| 391 | if self.slot_data.get("shuffle_worldports", False): | ||
| 392 | self.set_notify(self.get_datastorage_key("worldports")) | ||
| 393 | msg_batch.append({ | ||
| 394 | "cmd": "Set", | ||
| 395 | "key": self.get_datastorage_key("worldports"), | ||
| 396 | "default": [], | ||
| 397 | "want_reply": True, | ||
| 398 | "operations": [{"operation": "default", "value": []}] | ||
| 399 | }) | ||
| 400 | |||
| 401 | async_start(self.send_msgs(msg_batch), name="default keys") | ||
| 402 | elif cmd == "RoomUpdate": | ||
| 403 | if "checked_locations" in args: | ||
| 404 | self.manager.tracker.set_checked_locations(self.checked_locations) | ||
| 405 | self.manager.game_ctx.send_update_locations(args["checked_locations"]) | ||
| 406 | elif cmd == "ReceivedItems": | ||
| 407 | self.manager.tracker.set_collected_items(self.items_received) | ||
| 408 | |||
| 409 | cur_index = 0 | ||
| 410 | items = [] | ||
| 411 | |||
| 412 | for item in args["items"]: | ||
| 413 | index = cur_index + args["index"] | ||
| 414 | cur_index += 1 | ||
| 415 | |||
| 416 | item_msg = { | ||
| 417 | "id": item.item, | ||
| 418 | "index": index, | ||
| 419 | "flags": item.flags, | ||
| 420 | "text": self.item_names.lookup_in_slot(item.item, self.slot), | ||
| 421 | } | ||
| 422 | |||
| 423 | if item.player != self.slot: | ||
| 424 | item_msg["sender"] = self.player_names.get(item.player) | ||
| 425 | |||
| 426 | items.append(item_msg) | ||
| 427 | |||
| 428 | self.manager.game_ctx.send_item_received(items) | ||
| 429 | |||
| 430 | if any(ItemClassification.progression in ItemClassification(item.flags) for item in args["items"]): | ||
| 431 | self.manager.game_ctx.send_accessible_locations() | ||
| 432 | elif cmd == "PrintJSON": | ||
| 433 | if "receiving" in args and "item" in args and args["item"].player == self.slot: | ||
| 434 | item_name = self.item_names.lookup_in_slot(args["item"].item, args["receiving"]) | ||
| 435 | location_name = self.location_names.lookup_in_slot(args["item"].location, args["item"].player) | ||
| 436 | receiver_name = self.player_names.get(args["receiving"]) | ||
| 437 | |||
| 438 | if args["type"] == "Hint" and not args.get("found", False): | ||
| 439 | self.manager.game_ctx.send_hint_received(item_name, location_name, receiver_name, args["item"].flags, | ||
| 440 | int(args["receiving"]) == self.slot) | ||
| 441 | elif args["receiving"] != self.slot: | ||
| 442 | self.manager.game_ctx.send_item_sent_notification(item_name, receiver_name, args["item"].flags) | ||
| 443 | |||
| 444 | parts = [] | ||
| 445 | for message_part in args["data"]: | ||
| 446 | if "type" not in message_part and "text" in message_part: | ||
| 447 | parts.append({"type": "text", "text": message_part["text"]}) | ||
| 448 | elif message_part["type"] == "player_id": | ||
| 449 | parts.append({ | ||
| 450 | "type": "player", | ||
| 451 | "text": self.player_names.get(int(message_part["text"])), | ||
| 452 | "self": int(int(message_part["text"]) == self.slot), | ||
| 453 | }) | ||
| 454 | elif message_part["type"] == "item_id": | ||
| 455 | parts.append({ | ||
| 456 | "type": "item", | ||
| 457 | "text": self.item_names.lookup_in_slot(int(message_part["text"]), message_part["player"]), | ||
| 458 | "flags": message_part["flags"], | ||
| 459 | }) | ||
| 460 | elif message_part["type"] == "location_id": | ||
| 461 | parts.append({ | ||
| 462 | "type": "location", | ||
| 463 | "text": self.location_names.lookup_in_slot(int(message_part["text"]), | ||
| 464 | message_part["player"]) | ||
| 465 | }) | ||
| 466 | elif "text" in message_part: | ||
| 467 | parts.append({"type": "text", "text": message_part["text"]}) | ||
| 468 | |||
| 469 | self.manager.game_ctx.send_text_message(parts) | ||
| 470 | elif cmd == "LocationInfo": | ||
| 471 | locations = [] | ||
| 472 | |||
| 473 | for location in args["locations"]: | ||
| 474 | locations.append({ | ||
| 475 | "id": location.location, | ||
| 476 | "item": self.item_names.lookup_in_slot(location.item, location.player), | ||
| 477 | "player": self.player_names.get(location.player), | ||
| 478 | "flags": location.flags, | ||
| 479 | "self": int(location.player) == self.slot, | ||
| 480 | }) | ||
| 481 | |||
| 482 | self.manager.game_ctx.send_location_info(locations) | ||
| 483 | elif cmd == "Retrieved": | ||
| 484 | for k, v in args["keys"].items(): | ||
| 485 | if k == self.victory_data_storage_key: | ||
| 486 | self.handle_status_update(v) | ||
| 487 | elif k == self.hints_data_storage_key: | ||
| 488 | self.update_hints() | ||
| 489 | elif cmd == "SetReply": | ||
| 490 | if args["key"] == self.get_datastorage_key("keyboard1"): | ||
| 491 | self.handle_keyboard_update(1, args) | ||
| 492 | elif args["key"] == self.get_datastorage_key("keyboard2"): | ||
| 493 | self.handle_keyboard_update(2, args) | ||
| 494 | elif args["key"] == self.get_datastorage_key("worldports"): | ||
| 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) | ||
| 497 | if len(updates) > 0: | ||
| 498 | self.manager.game_ctx.send_update_worldports(updates) | ||
| 499 | elif args["key"] == self.victory_data_storage_key: | ||
| 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() | ||
| 510 | |||
| 511 | def get_datastorage_key(self, name: str): | ||
| 512 | return f"Lingo2_{self.slot}_{name}" | ||
| 513 | |||
| 514 | async def update_keyboard(self, updates: dict[str, int]): | ||
| 515 | kb1 = 0 | ||
| 516 | kb2 = 0 | ||
| 517 | |||
| 518 | for k, v in updates.items(): | ||
| 519 | if v == 0: | ||
| 520 | continue | ||
| 521 | |||
| 522 | effect = 0 | ||
| 523 | if v >= 1: | ||
| 524 | effect |= 1 | ||
| 525 | if v == 2: | ||
| 526 | effect |= 2 | ||
| 527 | |||
| 528 | pos = KEY_STORAGE_MAPPING[k] | ||
| 529 | if pos[0] == 1: | ||
| 530 | kb1 |= (effect << pos[1] * 2) | ||
| 531 | else: | ||
| 532 | kb2 |= (effect << pos[1] * 2) | ||
| 533 | |||
| 534 | msgs = [] | ||
| 535 | |||
| 536 | if kb1 != 0: | ||
| 537 | msgs.append({ | ||
| 538 | "cmd": "Set", | ||
| 539 | "key": self.get_datastorage_key("keyboard1"), | ||
| 540 | "want_reply": True, | ||
| 541 | "operations": [{ | ||
| 542 | "operation": "or", | ||
| 543 | "value": kb1 | ||
| 544 | }] | ||
| 545 | }) | ||
| 546 | |||
| 547 | if kb2 != 0: | ||
| 548 | msgs.append({ | ||
| 549 | "cmd": "Set", | ||
| 550 | "key": self.get_datastorage_key("keyboard2"), | ||
| 551 | "want_reply": True, | ||
| 552 | "operations": [{ | ||
| 553 | "operation": "or", | ||
| 554 | "value": kb2 | ||
| 555 | }] | ||
| 556 | }) | ||
| 557 | |||
| 558 | if len(msgs) > 0: | ||
| 559 | await self.send_msgs(msgs) | ||
| 560 | |||
| 561 | def handle_keyboard_update(self, field: int, args: dict[str, Any]): | ||
| 562 | keys = {} | ||
| 563 | value = args["value"] | ||
| 564 | |||
| 565 | for i in range(0, 13): | ||
| 566 | if (value & (1 << (i * 2))) != 0: | ||
| 567 | keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 1 | ||
| 568 | if (value & (1 << (i * 2 + 1))) != 0: | ||
| 569 | keys[REVERSE_KEY_STORAGE_MAPPING[(field, i)]] = 2 | ||
| 570 | |||
| 571 | updates = self.manager.update_keyboard(keys) | ||
| 572 | if len(updates) > 0: | ||
| 573 | self.manager.game_ctx.send_update_keyboard(updates) | ||
| 574 | |||
| 575 | # Input should be real IDs, not AP IDs | ||
| 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] | ||
| 578 | await self.send_msgs([{ | ||
| 579 | "cmd": "Set", | ||
| 580 | "key": self.get_datastorage_key("worldports"), | ||
| 581 | "want_reply": True, | ||
| 582 | "operations": [{ | ||
| 583 | "operation": "update", | ||
| 584 | "value": port_ap_ids | ||
| 585 | }] | ||
| 586 | }]) | ||
| 587 | |||
| 588 | def handle_status_update(self, value: int): | ||
| 589 | self.manager.goaled = (value == ClientStatus.CLIENT_GOAL) | ||
| 590 | self.manager.tracker.refresh_state() | ||
| 591 | self.manager.game_ctx.send_accessible_locations() | ||
| 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 | |||
| 635 | |||
| 636 | async def pipe_loop(manager: Lingo2Manager): | ||
| 637 | while not manager.client_ctx.exit_event.is_set(): | ||
| 638 | try: | ||
| 639 | socket = await websockets.connect("ws://localhost", port=PORT, ping_timeout=None, ping_interval=None, | ||
| 640 | max_size=MESSAGE_MAX_SIZE) | ||
| 641 | manager.game_ctx.server = Endpoint(socket) | ||
| 642 | logger.info("Connected to Lingo 2!") | ||
| 643 | if manager.client_ctx.auth is not None: | ||
| 644 | manager.game_ctx.send_connected() | ||
| 645 | manager.game_ctx.send_accessible_locations() | ||
| 646 | async for data in manager.game_ctx.server.socket: | ||
| 647 | for msg in decode(data): | ||
| 648 | await process_game_cmd(manager, msg) | ||
| 649 | except ConnectionRefusedError: | ||
| 650 | logger.info("Could not connect to Lingo 2.") | ||
| 651 | finally: | ||
| 652 | manager.game_ctx.server = None | ||
| 653 | |||
| 654 | |||
| 655 | async def process_game_cmd(manager: Lingo2Manager, args: dict): | ||
| 656 | cmd = args["cmd"] | ||
| 657 | |||
| 658 | if cmd == "Connect": | ||
| 659 | manager.client_ctx.seed_name = None | ||
| 660 | |||
| 661 | server = args.get("server") | ||
| 662 | player = args.get("player") | ||
| 663 | password = args.get("password") | ||
| 664 | |||
| 665 | if password != "": | ||
| 666 | server_address = f"{player}:{password}@{server}" | ||
| 667 | else: | ||
| 668 | server_address = f"{player}:None@{server}" | ||
| 669 | |||
| 670 | async_start(manager.client_ctx.connect(server_address), name="client connect") | ||
| 671 | elif cmd == "Disconnect": | ||
| 672 | manager.client_ctx.seed_name = None | ||
| 673 | |||
| 674 | async_start(manager.client_ctx.disconnect(), name="client disconnect") | ||
| 675 | elif cmd in ["Sync", "LocationChecks", "Say", "StatusUpdate", "LocationScouts"]: | ||
| 676 | async_start(manager.client_ctx.send_msgs([args]), name="client forward") | ||
| 677 | elif cmd == "UpdateKeyboard": | ||
| 678 | updates = manager.update_keyboard(args["keyboard"]) | ||
| 679 | if len(updates) > 0: | ||
| 680 | async_start(manager.client_ctx.update_keyboard(updates), name="client update keyboard") | ||
| 681 | elif cmd == "CheckWorldport": | ||
| 682 | port_id = args["port_id"] | ||
| 683 | port_ap_id = Lingo2World.static_logic.objects.ports[port_id].ap_id | ||
| 684 | worldports = {port_id} | ||
| 685 | |||
| 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)]]) | ||
| 691 | |||
| 692 | updates = manager.update_worldports(worldports) | ||
| 693 | if len(updates) > 0: | ||
| 694 | async_start(manager.client_ctx.update_worldports(updates), name="client update worldports") | ||
| 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") | ||
| 715 | elif cmd == "Quit": | ||
| 716 | manager.client_ctx.exit_event.set() | ||
| 717 | |||
| 718 | |||
| 719 | async def run_game(): | ||
| 720 | exe_file = settings.get_settings().lingo2_options.exe_file | ||
| 721 | |||
| 722 | # This ensures we can use Steam features without having to open the game | ||
| 723 | # through steam. | ||
| 724 | steam_appid_path = os.path.join(os.path.dirname(exe_file), "steam_appid.txt") | ||
| 725 | with open(steam_appid_path, "w") as said_handle: | ||
| 726 | said_handle.write("2523310") | ||
| 727 | |||
| 728 | if Lingo2World.zip_path is not None: | ||
| 729 | # This is a packaged apworld. | ||
| 730 | init_scene = pkgutil.get_data(__name__, "client/run_from_apworld.tscn") | ||
| 731 | init_path = Utils.local_path("data", "lingo2_init.tscn") | ||
| 732 | |||
| 733 | with open(init_path, "wb") as file_handle: | ||
| 734 | file_handle.write(init_scene) | ||
| 735 | |||
| 736 | subprocess.Popen( | ||
| 737 | [ | ||
| 738 | exe_file, | ||
| 739 | "--scene", | ||
| 740 | init_path, | ||
| 741 | "--", | ||
| 742 | str(Lingo2World.zip_path.absolute()), | ||
| 743 | ], | ||
| 744 | cwd=os.path.dirname(exe_file), | ||
| 745 | ) | ||
| 746 | else: | ||
| 747 | # The world is unzipped and being run in source. | ||
| 748 | subprocess.Popen( | ||
| 749 | [ | ||
| 750 | exe_file, | ||
| 751 | "--scene", | ||
| 752 | Utils.local_path("worlds", "lingo2", "client", "run_from_source.tscn"), | ||
| 753 | "--", | ||
| 754 | Utils.local_path("worlds", "lingo2", "client"), | ||
| 755 | ], | ||
| 756 | cwd=os.path.dirname(exe_file), | ||
| 757 | ) | ||
| 758 | |||
| 759 | |||
| 760 | def client_main(*launch_args: str) -> None: | ||
| 761 | async def main(args): | ||
| 762 | if settings.get_settings().lingo2_options.start_game: | ||
| 763 | async_start(run_game()) | ||
| 764 | |||
| 765 | client_ctx = Lingo2ClientContext(args.connect, args.password) | ||
| 766 | client_ctx.auth = args.name | ||
| 767 | |||
| 768 | game_ctx = Lingo2GameContext() | ||
| 769 | manager = Lingo2Manager(game_ctx, client_ctx) | ||
| 770 | |||
| 771 | client_ctx.server_task = asyncio.create_task(server_loop(client_ctx), name="ServerLoop") | ||
| 772 | |||
| 773 | if gui_enabled: | ||
| 774 | client_ctx.run_gui() | ||
| 775 | client_ctx.run_cli() | ||
| 776 | |||
| 777 | pipe_task = asyncio.create_task(pipe_loop(manager), name="GameWatcher") | ||
| 778 | |||
| 779 | try: | ||
| 780 | await pipe_task | ||
| 781 | except Exception as e: | ||
| 782 | logger.exception(e) | ||
| 783 | |||
| 784 | await client_ctx.exit_event.wait() | ||
| 785 | client_ctx.ui.stop() | ||
| 786 | await client_ctx.shutdown() | ||
| 787 | |||
| 788 | Utils.init_logging("Lingo2Client", exception_logger="Client") | ||
| 789 | import colorama | ||
| 790 | |||
| 791 | parser = get_base_parser(description="Lingo 2 Archipelago Client") | ||
| 792 | parser.add_argument('--name', default=None, help="Slot Name to connect as.") | ||
| 793 | parser.add_argument("url", nargs="?", help="Archipelago connection url") | ||
| 794 | args = parser.parse_args(launch_args) | ||
| 795 | |||
| 796 | args = handle_url_arg(args, parser=parser) | ||
| 797 | |||
| 798 | colorama.just_fix_windows_console() | ||
| 799 | asyncio.run(main(args)) | ||
| 800 | colorama.deinit() | ||
