528 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			528 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
|   | import json | ||
|  | import logging | ||
|  | import queue | ||
|  | import time | ||
|  | import struct | ||
|  | import random | ||
|  | from dataclasses import dataclass | ||
|  | from queue import Queue | ||
|  | from typing import Callable | ||
|  | 
 | ||
|  | import pymem | ||
|  | from pymem.exception import ProcessNotFound, ProcessError | ||
|  | 
 | ||
|  | import asyncio | ||
|  | from asyncio import StreamReader, StreamWriter, Lock | ||
|  | 
 | ||
|  | from NetUtils import NetworkItem | ||
|  | from ..game_id import jak1_id, jak1_max | ||
|  | from ..items import item_table, trap_item_table | ||
|  | from ..locs import ( | ||
|  |     orb_locations as orbs, | ||
|  |     cell_locations as cells, | ||
|  |     scout_locations as flies, | ||
|  |     special_locations as specials, | ||
|  |     orb_cache_locations as caches) | ||
|  | 
 | ||
|  | 
 | ||
|  | logger = logging.getLogger("ReplClient") | ||
|  | 
 | ||
|  | 
 | ||
|  | @dataclass | ||
|  | class JsonMessageData: | ||
|  |     my_item_name: str | None = None | ||
|  |     my_item_finder: str | None = None | ||
|  |     their_item_name: str | None = None | ||
|  |     their_item_owner: str | None = None | ||
|  | 
 | ||
|  | 
 | ||
|  | ALLOWED_CHARACTERS = frozenset({ | ||
|  |     "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", | ||
|  |     "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", | ||
|  |     "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", | ||
|  |     "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", | ||
|  |     "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", | ||
|  |     "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", | ||
|  |     "y", "z", " ", "!", ":", ",", ".", "/", "?", "-", | ||
|  |     "=", "+", "'", "(", ")", "\"" | ||
|  | }) | ||
|  | 
 | ||
|  | 
 | ||
|  | class JakAndDaxterReplClient: | ||
|  |     ip: str | ||
|  |     port: int | ||
|  |     reader: StreamReader | ||
|  |     writer: StreamWriter | ||
|  |     lock: Lock | ||
|  |     connected: bool = False | ||
|  |     initiated_connect: bool = False  # Signals when user tells us to try reconnecting. | ||
|  |     received_deathlink: bool = False | ||
|  |     balanced_orbs: bool = False | ||
|  | 
 | ||
|  |     # Variables to handle the title screen and initial game connection. | ||
|  |     initial_item_count = -1  # Brand new games have 0 items, so initialize this to -1. | ||
|  |     received_initial_items = False | ||
|  |     processed_initial_items = False | ||
|  | 
 | ||
|  |     # The REPL client needs the REPL/compiler process running, but that process | ||
|  |     # also needs the game running. Therefore, the REPL client needs both running. | ||
|  |     gk_process: pymem.process = None | ||
|  |     goalc_process: pymem.process = None | ||
|  | 
 | ||
|  |     item_inbox: dict[int, NetworkItem] = {} | ||
|  |     inbox_index = 0 | ||
|  |     json_message_queue: Queue[JsonMessageData] = queue.Queue() | ||
|  | 
 | ||
|  |     # Logging callbacks | ||
|  |     # These will write to the provided logger, as well as the Client GUI with color markup. | ||
|  |     log_error: Callable    # Red | ||
|  |     log_warn: Callable     # Orange | ||
|  |     log_success: Callable  # Green | ||
|  |     log_info: Callable     # White (default) | ||
|  | 
 | ||
|  |     def __init__(self, | ||
|  |                  log_error_callback: Callable, | ||
|  |                  log_warn_callback: Callable, | ||
|  |                  log_success_callback: Callable, | ||
|  |                  log_info_callback: Callable, | ||
|  |                  ip: str = "127.0.0.1", | ||
|  |                  port: int = 8181): | ||
|  |         self.ip = ip | ||
|  |         self.port = port | ||
|  |         self.lock = asyncio.Lock() | ||
|  |         self.log_error = log_error_callback | ||
|  |         self.log_warn = log_warn_callback | ||
|  |         self.log_success = log_success_callback | ||
|  |         self.log_info = log_info_callback | ||
|  | 
 | ||
|  |     async def main_tick(self): | ||
|  |         if self.initiated_connect: | ||
|  |             await self.connect() | ||
|  |             self.initiated_connect = False | ||
|  | 
 | ||
|  |         if self.connected: | ||
|  |             try: | ||
|  |                 self.gk_process.read_bool(self.gk_process.base_address)  # Ping to see if it's alive. | ||
|  |             except ProcessError: | ||
|  |                 msg = (f"Error reading game memory! (Did the game crash?)\n" | ||
|  |                        f"Please close all open windows and reopen the Jak and Daxter Client " | ||
|  |                        f"from the Archipelago Launcher.\n" | ||
|  |                        f"If the game and compiler do not restart automatically, please follow these steps:\n" | ||
|  |                        f"   Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n" | ||
|  |                        f"   Then click Advanced > Play in Debug Mode.\n" | ||
|  |                        f"   Then click Advanced > Open REPL.\n" | ||
|  |                        f"   Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.") | ||
|  |                 self.log_error(logger, msg) | ||
|  |                 self.connected = False | ||
|  |             try: | ||
|  |                 self.goalc_process.read_bool(self.goalc_process.base_address)  # Ping to see if it's alive. | ||
|  |             except ProcessError: | ||
|  |                 msg = (f"Error sending data to compiler! (Did the compiler crash?)\n" | ||
|  |                        f"Please close all open windows and reopen the Jak and Daxter Client " | ||
|  |                        f"from the Archipelago Launcher.\n" | ||
|  |                        f"If the game and compiler do not restart automatically, please follow these steps:\n" | ||
|  |                        f"   Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n" | ||
|  |                        f"   Then click Advanced > Play in Debug Mode.\n" | ||
|  |                        f"   Then click Advanced > Open REPL.\n" | ||
|  |                        f"   Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.") | ||
|  |                 self.log_error(logger, msg) | ||
|  |                 self.connected = False | ||
|  |         else: | ||
|  |             return | ||
|  | 
 | ||
|  |         # When connecting the game to the AP server on the title screen, we may be processing items from starting | ||
|  |         # inventory or items received in an async game. Once we have caught up to the initial count, tell the player | ||
|  |         # that we are ready to start. New items may even come in during the title screen, so if we go over the count, | ||
|  |         # we should still send the ready signal. | ||
|  |         if not self.processed_initial_items: | ||
|  |             if self.inbox_index >= self.initial_item_count >= 0: | ||
|  |                 self.processed_initial_items = True | ||
|  |                 await self.send_connection_status("ready") | ||
|  | 
 | ||
|  |         # Receive Items from AP. Handle 1 item per tick. | ||
|  |         if len(self.item_inbox) > self.inbox_index: | ||
|  |             await self.receive_item() | ||
|  |             await self.save_data() | ||
|  |             self.inbox_index += 1 | ||
|  | 
 | ||
|  |         if self.received_deathlink: | ||
|  |             await self.receive_deathlink() | ||
|  |             self.received_deathlink = False | ||
|  | 
 | ||
|  |         # Progressively empty the queue during each tick | ||
|  |         # if text messages happen to be too slow we could pool dequeuing here,  | ||
|  |         # but it'd slow down the ItemReceived message during release | ||
|  |         if not self.json_message_queue.empty(): | ||
|  |             json_txt_data = self.json_message_queue.get_nowait() | ||
|  |             await self.write_game_text(json_txt_data) | ||
|  | 
 | ||
|  |     # This helper function formats and sends `form` as a command to the REPL. | ||
|  |     # ALL commands to the REPL should be sent using this function. | ||
|  |     async def send_form(self, form: str, print_ok: bool = True) -> bool: | ||
|  |         header = struct.pack("<II", len(form), 10) | ||
|  |         async with self.lock: | ||
|  |             self.writer.write(header + form.encode()) | ||
|  |             await self.writer.drain() | ||
|  | 
 | ||
|  |             response_data = await self.reader.read(1024) | ||
|  |             response = response_data.decode() | ||
|  | 
 | ||
|  |             if "OK!" in response: | ||
|  |                 if print_ok: | ||
|  |                     logger.debug(response) | ||
|  |                 return True | ||
|  |             else: | ||
|  |                 self.log_error(logger, f"Unexpected response from REPL: {response}") | ||
|  |                 return False | ||
|  | 
 | ||
|  |     async def connect(self): | ||
|  |         try: | ||
|  |             self.gk_process = pymem.Pymem("gk.exe")  # The GOAL Kernel | ||
|  |             logger.debug("Found the gk process: " + str(self.gk_process.process_id)) | ||
|  |         except ProcessNotFound: | ||
|  |             self.log_error(logger, "Could not find the game process.") | ||
|  |             return | ||
|  | 
 | ||
|  |         try: | ||
|  |             self.goalc_process = pymem.Pymem("goalc.exe")  # The GOAL Compiler and REPL | ||
|  |             logger.debug("Found the goalc process: " + str(self.goalc_process.process_id)) | ||
|  |         except ProcessNotFound: | ||
|  |             self.log_error(logger, "Could not find the compiler process.") | ||
|  |             return | ||
|  | 
 | ||
|  |         try: | ||
|  |             self.reader, self.writer = await asyncio.open_connection(self.ip, self.port) | ||
|  |             time.sleep(1) | ||
|  |             connect_data = await self.reader.read(1024) | ||
|  |             welcome_message = connect_data.decode() | ||
|  | 
 | ||
|  |             # Should be the OpenGOAL welcome message (ignore version number). | ||
|  |             if "Connected to OpenGOAL" and "nREPL!" in welcome_message: | ||
|  |                 logger.debug(welcome_message) | ||
|  |             else: | ||
|  |                 self.log_error(logger, | ||
|  |                                f"Unable to connect to REPL websocket: unexpected welcome message \"{welcome_message}\"") | ||
|  |         except ConnectionRefusedError as e: | ||
|  |             self.log_error(logger, f"Unable to connect to REPL websocket: {e.strerror}") | ||
|  |             return | ||
|  | 
 | ||
|  |         ok_count = 0 | ||
|  |         if self.reader and self.writer: | ||
|  | 
 | ||
|  |             # Have the REPL listen to the game's internal websocket. | ||
|  |             if await self.send_form("(lt)", print_ok=False): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             # Show this visual cue when compilation is started. | ||
|  |             # It's the version number of the OpenGOAL Compiler. | ||
|  |             if await self.send_form("(set! *debug-segment* #t)", print_ok=False): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             # Start compilation. This is blocking, so nothing will happen until the REPL is done. | ||
|  |             if await self.send_form("(mi)", print_ok=False): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             # Play this audio cue when compilation is complete. | ||
|  |             # It's the sound you hear when you press START + START to close the Options menu. | ||
|  |             if await self.send_form("(dotimes (i 1) " | ||
|  |                                     "(sound-play-by-name " | ||
|  |                                     "(static-sound-name \"menu-close\") " | ||
|  |                                     "(new-sound-id) 1024 0 0 (sound-group sfx) #t))", print_ok=False): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             # Disable cheat-mode and debug (close the visual cues). | ||
|  |             if await self.send_form("(set! *debug-segment* #f)", print_ok=False): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             if await self.send_form("(set! *cheat-mode* #f)", print_ok=False): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             # Run the retail game start sequence (while still connected with REPL). | ||
|  |             if await self.send_form("(start \'play (get-continue-by-name *game-info* \"title-start\"))"): | ||
|  |                 ok_count += 1 | ||
|  | 
 | ||
|  |             # Now wait until we see the success message... 7 times. | ||
|  |             if ok_count == 7: | ||
|  |                 self.connected = True | ||
|  |             else: | ||
|  |                 self.connected = False | ||
|  | 
 | ||
|  |         if self.connected: | ||
|  |             self.log_success(logger, "The REPL is ready!") | ||
|  | 
 | ||
|  |     async def print_status(self): | ||
|  |         gc_proc_id = str(self.goalc_process.process_id) if self.goalc_process else "None" | ||
|  |         gk_proc_id = str(self.gk_process.process_id) if self.gk_process else "None" | ||
|  |         msg = (f"REPL Status:\n" | ||
|  |                f"   REPL process ID: {gc_proc_id}\n" | ||
|  |                f"   Game process ID: {gk_proc_id}\n") | ||
|  |         try: | ||
|  |             if self.reader and self.writer: | ||
|  |                 addr = self.writer.get_extra_info("peername") | ||
|  |                 addr = str(addr) if addr else "None" | ||
|  |                 msg += f"   Game websocket: {addr}\n" | ||
|  |                 await self.send_form("(dotimes (i 1) " | ||
|  |                                      "(sound-play-by-name " | ||
|  |                                      "(static-sound-name \"menu-close\") " | ||
|  |                                      "(new-sound-id) 1024 0 0 (sound-group sfx) #t))", print_ok=False) | ||
|  |         except ConnectionResetError: | ||
|  |             msg += f"   Connection to the game was lost or reset!" | ||
|  |         last_item = str(getattr(self.item_inbox[self.inbox_index], "item")) if self.inbox_index else "None" | ||
|  |         msg += f"   Last item received: {last_item}\n" | ||
|  |         msg += f"   Did you hear the success audio cue?" | ||
|  |         self.log_info(logger, msg) | ||
|  | 
 | ||
|  |     # To properly display in-game text: | ||
|  |     # - It must be a valid character from the ALLOWED_CHARACTERS list. | ||
|  |     # - All lowercase letters must be uppercase. | ||
|  |     # - It must be wrapped in double quotes (for the REPL command). | ||
|  |     # - Apostrophes must be handled specially - GOAL uses invisible ASCII character 0x12. | ||
|  |     # I also only allotted 32 bytes to each string in OpenGOAL, so we must truncate. | ||
|  |     @staticmethod | ||
|  |     def sanitize_game_text(text: str) -> str: | ||
|  |         result = "".join([c if c in ALLOWED_CHARACTERS else "?" for c in text[:32]]).upper() | ||
|  |         result = result.replace("'", "\\c12") | ||
|  |         return f"\"{result}\"" | ||
|  | 
 | ||
|  |     # Like sanitize_game_text, but the settings file will NOT allow any whitespace in the slot_name or slot_seed data. | ||
|  |     # And don't replace any chars with "?" for good measure. | ||
|  |     @staticmethod | ||
|  |     def sanitize_file_text(text: str) -> str: | ||
|  |         allowed_chars_no_extras = ALLOWED_CHARACTERS - {" ", "'", "(", ")", "\""} | ||
|  |         result = "".join([c if c in allowed_chars_no_extras else "" for c in text[:16]]).upper() | ||
|  |         return f"\"{result}\"" | ||
|  | 
 | ||
|  |     # Pushes a JsonMessageData object to the json message queue to be processed during the repl main_tick | ||
|  |     def queue_game_text(self, my_item_name, my_item_finder, their_item_name, their_item_owner): | ||
|  |         self.json_message_queue.put(JsonMessageData(my_item_name, my_item_finder, their_item_name, their_item_owner)) | ||
|  | 
 | ||
|  |     # OpenGOAL can handle both its own string datatype and C-like character pointers (charp). | ||
|  |     async def write_game_text(self, data: JsonMessageData): | ||
|  |         logger.debug(f"Sending info to the in-game messenger!") | ||
|  |         body = "" | ||
|  |         if data.my_item_name and data.my_item_finder: | ||
|  |             body += (f" (append-messages (-> *ap-messenger* 0) \'recv " | ||
|  |                      f" {self.sanitize_game_text(data.my_item_name)} " | ||
|  |                      f" {self.sanitize_game_text(data.my_item_finder)})") | ||
|  |         if data.their_item_name and data.their_item_owner: | ||
|  |             body += (f" (append-messages (-> *ap-messenger* 0) \'sent " | ||
|  |                      f" {self.sanitize_game_text(data.their_item_name)} " | ||
|  |                      f" {self.sanitize_game_text(data.their_item_owner)})") | ||
|  |         await self.send_form(f"(begin {body} (none))", print_ok=False) | ||
|  | 
 | ||
|  |     async def receive_item(self): | ||
|  |         ap_id = getattr(self.item_inbox[self.inbox_index], "item") | ||
|  | 
 | ||
|  |         # Determine the type of item to receive. | ||
|  |         if ap_id in range(jak1_id, jak1_id + flies.fly_offset): | ||
|  |             await self.receive_power_cell(ap_id) | ||
|  |         elif ap_id in range(jak1_id + flies.fly_offset, jak1_id + specials.special_offset): | ||
|  |             await self.receive_scout_fly(ap_id) | ||
|  |         elif ap_id in range(jak1_id + specials.special_offset, jak1_id + caches.orb_cache_offset): | ||
|  |             await self.receive_special(ap_id) | ||
|  |         elif ap_id in range(jak1_id + caches.orb_cache_offset, jak1_id + orbs.orb_offset): | ||
|  |             await self.receive_move(ap_id) | ||
|  |         elif ap_id in range(jak1_id + orbs.orb_offset, jak1_max - max(trap_item_table)): | ||
|  |             await self.receive_precursor_orb(ap_id)  # Ponder the orbs. | ||
|  |         elif ap_id in range(jak1_max - max(trap_item_table), jak1_max): | ||
|  |             await self.receive_trap(ap_id) | ||
|  |         elif ap_id == jak1_max: | ||
|  |             await self.receive_green_eco()  # Ponder why I chose to do ID's this way. | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Tried to receive item with unknown AP ID {ap_id}!") | ||
|  | 
 | ||
|  |     async def receive_power_cell(self, ap_id: int) -> bool: | ||
|  |         cell_id = cells.to_game_id(ap_id) | ||
|  |         ok = await self.send_form("(send-event " | ||
|  |                                   "*target* \'get-archipelago " | ||
|  |                                   "(pickup-type fuel-cell) " | ||
|  |                                   "(the float " + str(cell_id) + "))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received a Power Cell!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive a Power Cell!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def receive_scout_fly(self, ap_id: int) -> bool: | ||
|  |         fly_id = flies.to_game_id(ap_id) | ||
|  |         ok = await self.send_form("(send-event " | ||
|  |                                   "*target* \'get-archipelago " | ||
|  |                                   "(pickup-type buzzer) " | ||
|  |                                   "(the float " + str(fly_id) + "))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received a {item_table[ap_id]}!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive a {item_table[ap_id]}!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def receive_special(self, ap_id: int) -> bool: | ||
|  |         special_id = specials.to_game_id(ap_id) | ||
|  |         ok = await self.send_form("(send-event " | ||
|  |                                   "*target* \'get-archipelago " | ||
|  |                                   "(pickup-type ap-special) " | ||
|  |                                   "(the float " + str(special_id) + "))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received special unlock {item_table[ap_id]}!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive special unlock {item_table[ap_id]}!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def receive_move(self, ap_id: int) -> bool: | ||
|  |         move_id = caches.to_game_id(ap_id) | ||
|  |         ok = await self.send_form("(send-event " | ||
|  |                                   "*target* \'get-archipelago " | ||
|  |                                   "(pickup-type ap-move) " | ||
|  |                                   "(the float " + str(move_id) + "))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received the ability to {item_table[ap_id]}!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive the ability to {item_table[ap_id]}!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def receive_precursor_orb(self, ap_id: int) -> bool: | ||
|  |         orb_amount = orbs.to_game_id(ap_id) | ||
|  |         ok = await self.send_form("(send-event " | ||
|  |                                   "*target* \'get-archipelago " | ||
|  |                                   "(pickup-type money) " | ||
|  |                                   "(the float " + str(orb_amount) + "))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received {orb_amount} Precursor orbs!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive {orb_amount} Precursor orbs!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def receive_trap(self, ap_id: int) -> bool: | ||
|  |         trap_id = jak1_max - ap_id | ||
|  |         ok = await self.send_form("(send-event " | ||
|  |                                   "*target* \'get-archipelago " | ||
|  |                                   "(pickup-type ap-trap) " | ||
|  |                                   "(the float " + str(trap_id) + "))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received a {item_table[ap_id]}!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive a {item_table[ap_id]}!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     # Green eco pills are our filler item. Use the get-pickup event instead to handle being full health. | ||
|  |     async def receive_green_eco(self) -> bool: | ||
|  |         ok = await self.send_form("(send-event *target* \'get-pickup (pickup-type eco-pill) (the float 1))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received a green eco pill!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive a green eco pill!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def receive_deathlink(self) -> bool: | ||
|  | 
 | ||
|  |         # Because it should at least be funny sometimes. | ||
|  |         death_types = ["\'death", | ||
|  |                        "\'death", | ||
|  |                        "\'death", | ||
|  |                        "\'death", | ||
|  |                        "\'endlessfall", | ||
|  |                        "\'drown-death", | ||
|  |                        "\'melt", | ||
|  |                        "\'dark-eco-pool"] | ||
|  |         chosen_death = random.choice(death_types) | ||
|  | 
 | ||
|  |         ok = await self.send_form("(ap-deathlink-received! " + chosen_death + ")") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Received deathlink signal!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Unable to receive deathlink signal!") | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def subtract_traded_orbs(self, orb_count: int) -> bool: | ||
|  | 
 | ||
|  |         # To protect against momentary server disconnects, | ||
|  |         # this should only be done once per client session. | ||
|  |         if not self.balanced_orbs: | ||
|  |             self.balanced_orbs = True | ||
|  | 
 | ||
|  |             ok = await self.send_form(f"(-! (-> *game-info* money) (the float {orb_count}))") | ||
|  |             if ok: | ||
|  |                 logger.debug(f"Subtracting {orb_count} traded orbs!") | ||
|  |             else: | ||
|  |                 self.log_error(logger, f"Unable to subtract {orb_count} traded orbs!") | ||
|  |             return ok | ||
|  | 
 | ||
|  |         return True | ||
|  | 
 | ||
|  |     # OpenGOAL has a limit of 8 parameters per function. We've already hit this limit. So, define a new datatype | ||
|  |     # in OpenGOAL that holds all these options, instantiate the type here, and have ap-setup-options! function take | ||
|  |     # that instance as input. | ||
|  |     async def setup_options(self, | ||
|  |                             os_option: int, os_bundle: int, | ||
|  |                             fc_count: int, mp_count: int, | ||
|  |                             lt_count: int, ct_amount: int, | ||
|  |                             ot_amount: int, trap_time: int, | ||
|  |                             goal_id: int, slot_name: str, | ||
|  |                             slot_seed: str) -> bool: | ||
|  |         sanitized_name = self.sanitize_file_text(slot_name) | ||
|  |         sanitized_seed = self.sanitize_file_text(slot_seed) | ||
|  | 
 | ||
|  |         # I didn't want to have to do this with floats but GOAL's compile-time vs runtime types leave me no choice. | ||
|  |         ok = await self.send_form(f"(ap-setup-options! (new 'static 'ap-seed-options " | ||
|  |                                   f":orbsanity-option {os_option} " | ||
|  |                                   f":orbsanity-bundle {os_bundle} " | ||
|  |                                   f":fire-canyon-unlock {fc_count}.0 " | ||
|  |                                   f":mountain-pass-unlock {mp_count}.0 " | ||
|  |                                   f":lava-tube-unlock {lt_count}.0 " | ||
|  |                                   f":citizen-orb-amount {ct_amount}.0 " | ||
|  |                                   f":oracle-orb-amount {ot_amount}.0 " | ||
|  |                                   f":trap-duration {trap_time}.0 " | ||
|  |                                   f":completion-goal {goal_id} " | ||
|  |                                   f":slot-name {sanitized_name} " | ||
|  |                                   f":slot-seed {sanitized_seed} ))") | ||
|  |         message = (f"Setting options: \n" | ||
|  |                    f"   orbsanity Option {os_option}, orbsanity Bundle {os_bundle}, \n" | ||
|  |                    f"   FC Cell Count {fc_count}, MP Cell Count {mp_count}, \n" | ||
|  |                    f"   LT Cell Count {lt_count}, Citizen Orb Amt {ct_amount}, \n" | ||
|  |                    f"   Oracle Orb Amt {ot_amount}, Trap Duration {trap_time}, \n" | ||
|  |                    f"   Completion GOAL {goal_id}, Slot Name {sanitized_name}, \n" | ||
|  |                    f"   Slot Seed {sanitized_seed}... ") | ||
|  |         if ok: | ||
|  |             logger.debug(message + "Success!") | ||
|  |         else: | ||
|  |             self.log_error(logger, message + "Failed!") | ||
|  | 
 | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def send_connection_status(self, status: str) -> bool: | ||
|  |         ok = await self.send_form(f"(ap-set-connection-status! (connection-status {status}))") | ||
|  |         if ok: | ||
|  |             logger.debug(f"Connection Status {status} set!") | ||
|  |         else: | ||
|  |             self.log_error(logger, f"Connection Status {status} failed to set!") | ||
|  | 
 | ||
|  |         return ok | ||
|  | 
 | ||
|  |     async def save_data(self): | ||
|  |         with open("jakanddaxter_item_inbox.json", "w+") as f: | ||
|  |             dump = { | ||
|  |                 "inbox_index": self.inbox_index, | ||
|  |                 "item_inbox": [{ | ||
|  |                     "item": self.item_inbox[k].item, | ||
|  |                     "location": self.item_inbox[k].location, | ||
|  |                     "player": self.item_inbox[k].player, | ||
|  |                     "flags": self.item_inbox[k].flags | ||
|  |                     } for k in self.item_inbox | ||
|  |                 ] | ||
|  |             } | ||
|  |             json.dump(dump, f, indent=4) | ||
|  | 
 | ||
|  |     def load_data(self): | ||
|  |         try: | ||
|  |             with open("jakanddaxter_item_inbox.json", "r") as f: | ||
|  |                 load = json.load(f) | ||
|  |                 self.inbox_index = load["inbox_index"] | ||
|  |                 self.item_inbox = {k: NetworkItem( | ||
|  |                         item=load["item_inbox"][k]["item"], | ||
|  |                         location=load["item_inbox"][k]["location"], | ||
|  |                         player=load["item_inbox"][k]["player"], | ||
|  |                         flags=load["item_inbox"][k]["flags"] | ||
|  |                     ) for k in range(0, len(load["item_inbox"])) | ||
|  |                 } | ||
|  |         except FileNotFoundError: | ||
|  |             pass |