From 2a5c128267bd3b116f99ee299c166063faa98955 Mon Sep 17 00:00:00 2001 From: CaitSith2 Date: Mon, 13 Jun 2022 04:52:49 -0700 Subject: [PATCH] ChecksFinder Client refactored to import CommonClient components. --- ChecksFinderClient.py | 614 ++++-------------------------------------- kvui.py | 7 - 2 files changed, 56 insertions(+), 565 deletions(-) diff --git a/ChecksFinderClient.py b/ChecksFinderClient.py index bd6c9f88..edf40995 100644 --- a/ChecksFinderClient.py +++ b/ChecksFinderClient.py @@ -1,229 +1,54 @@ from __future__ import annotations import os -import logging import asyncio -import urllib.parse -import sys -import typing -import time -import websockets +import ModuleUpdate +ModuleUpdate.update() import Utils if __name__ == "__main__": Utils.init_logging("ChecksFinderClient", exception_logger="Client") -from MultiServer import CommandProcessor -from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission -from Utils import Version, stream_input -from worlds import network_data_package, AutoWorldRegister -from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \ - keep_alive -from worlds.checksfinder import ChecksFinderWorld +from NetUtils import NetworkItem, ClientStatus +from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ + CommonContext, server_loop -class ClientCommandProcessor(CommandProcessor): - def __init__(self, ctx: CommonContext): - self.ctx = ctx - - def output(self, text: str): - logger.info(text) - - def _cmd_exit(self) -> bool: - """Close connections and client""" - self.ctx.exit_event.set() - return True - - def _cmd_connect(self, address: str = "") -> bool: - """Connect to a MultiWorld Server""" - self.ctx.server_address = None - asyncio.create_task(self.ctx.connect(address if address else None), name="connecting") - return True - - def _cmd_disconnect(self) -> bool: - """Disconnect from a MultiWorld Server""" - self.ctx.server_address = None - asyncio.create_task(self.ctx.disconnect(), name="disconnecting") - return True - - def _cmd_received(self) -> bool: - """List all received items""" - logger.info(f'{len(self.ctx.items_received)} received items:') - for index, item in enumerate(self.ctx.items_received, 1): - self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}") - return True - - def _cmd_missing(self) -> bool: - """List all missing location checks, from your local game state""" - if not self.ctx.game: - self.output("No game set, cannot determine missing checks.") - return False - count = 0 - checked_count = 0 - for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items(): - if location_id < 0: - continue - if location_id not in self.ctx.locations_checked: - if location_id in self.ctx.missing_locations: - self.output('Missing: ' + location) - count += 1 - elif location_id in self.ctx.checked_locations: - self.output('Checked: ' + location) - count += 1 - checked_count += 1 - - if count: - self.output( - f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}") - else: - self.output("No missing location checks found.") - return True - - def _cmd_items(self): - """List all item names for the currently running game.""" - self.output(f"Item Names for {self.ctx.game}") - for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: - self.output(item_name) - - def _cmd_locations(self): - """List all location names for the currently running game.""" - self.output(f"Location Names for {self.ctx.game}") - for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: - self.output(location_name) - +class ChecksFinderClientCommandProcessor(ClientCommandProcessor): def _cmd_resync(self): """Manually trigger a resync.""" self.output(f"Syncing items.") self.ctx.syncing = True - def _cmd_ready(self): - """Send ready status to server.""" - self.ctx.ready = not self.ctx.ready - if self.ctx.ready: - state = ClientStatus.CLIENT_READY - self.output("Readied up.") - else: - state = ClientStatus.CLIENT_CONNECTED - self.output("Unreadied.") - asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") - def default(self, raw: str): - raw = self.ctx.on_user_say(raw) - if raw: - asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") - - -class CommonContext(): - tags: typing.Set[str] = {"AP"} - starting_reconnect_delay: int = 5 - current_reconnect_delay: int = starting_reconnect_delay - command_processor: int = ClientCommandProcessor - game = None - ui = None - keep_alive_task = None - items_handling: typing.Optional[int] = None - current_energy_link_value = 0 # to display in UI, gets set by server +class ChecksFinderContext(CommonContext): + command_processor: int = ChecksFinderClientCommandProcessor + game = "ChecksFinder" + items_handling = 0b111 # full remote def __init__(self, server_address, password): - # server state + super(ChecksFinderContext, self).__init__(server_address, password) self.send_index: int = 0 - self.server_address = server_address - self.password = password self.syncing = False self.awaiting_bridge = False - self.server_task = None - self.server: typing.Optional[Endpoint] = None - self.server_version = Version(0, 0, 0) - self.hint_cost: typing.Optional[int] = None - self.games: typing.Dict[int, str] = {} - self.permissions = { - "forfeit": "disabled", - "collect": "disabled", - "remaining": "disabled", - } - # own state - self.finished_game = False - self.ready = False - self.team = None - self.slot = None - self.auth = None - self.seed_name = None + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(ChecksFinderContext, self).server_auth(password_requested) + if not self.auth: # TODO: Replace this if block with await self.getusername() once that PR is merged in. + logger.info('Enter slot name:') + self.auth = await self.console_input() - self.locations_checked: typing.Set[int] = set() # local state - self.locations_scouted: typing.Set[int] = set() - self.items_received = [] - self.missing_locations: typing.Set[int] = set() - self.checked_locations: typing.Set[int] = set() # server state - self.locations_info = {} - - self.input_queue = asyncio.Queue() - self.input_requests = 0 - - self.last_death_link: float = time.time() # last send/received death link on AP layer - - # game state - self.player_names: typing.Dict[int: str] = {0: "Archipelago"} - self.exit_event = asyncio.Event() - self.watcher_event = asyncio.Event() - - self.slow_mode = False - self.jsontotextparser = JSONtoTextParser(self) - self.set_getters(network_data_package) - - # execution - self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") - - @property - def total_locations(self) -> typing.Optional[int]: - """Will return None until connected.""" - if self.checked_locations or self.missing_locations: - return len(self.checked_locations | self.missing_locations) + await self.send_connect() async def connection_closed(self): - self.auth = None - self.items_received = [] - self.locations_info = {} - self.server_version = Version(0, 0, 0) - if self.server and self.server.socket is not None: - await self.server.socket.close() - self.server = None - self.server_task = None + await super(ChecksFinderContext, self).connection_closed() path = os.path.expandvars(r"%localappdata%/ChecksFinder") for root, dirs, files in os.walk(path): for file in files: if file.find("obtain") <= -1: - os.remove(root+"/"+file) - - # noinspection PyAttributeOutsideInit - def set_getters(self, data_package: dict, network=False): - if not network: # local data; check if newer data was already downloaded - local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {}) - if local_package and local_package["version"] > network_data_package["version"]: - data_package: dict = local_package - elif network: # check if data from server is newer - - if data_package["version"] > network_data_package["version"]: - Utils.persistent_store("datapackage", "latest", network_data_package) - - item_lookup: dict = {} - locations_lookup: dict = {} - for game, gamedata in data_package["games"].items(): - for item_name, item_id in gamedata["item_name_to_id"].items(): - item_lookup[item_id] = item_name - for location_name, location_id in gamedata["location_name_to_id"].items(): - locations_lookup[location_id] = location_name - - def get_item_name_from_id(code: int): - return item_lookup.get(code, f'Unknown item (ID:{code})') - - self.item_name_getter = get_item_name_from_id - - def get_location_name_from_address(address: int): - return locations_lookup.get(address, f'Unknown location (ID:{address})') - - self.location_name_getter = get_location_name_from_address + os.remove(root + "/" + file) @property def endpoints(self): @@ -232,346 +57,53 @@ class CommonContext(): else: return [] - async def disconnect(self): - if self.server and not self.server.socket.closed: - await self.server.socket.close() - if self.server_task is not None: - await self.server_task - - async def send_msgs(self, msgs): - if not self.server or not self.server.socket.open or self.server.socket.closed: - return - await self.server.socket.send(encode(msgs)) - - def consume_players_package(self, package: typing.List[tuple]): - self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team} - self.player_names[0] = "Archipelago" - - def event_invalid_slot(self): - raise Exception('Invalid Slot; please verify that you have connected to the correct world.') - - def event_invalid_game(self): - raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.') - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - logger.info('Enter the password required to join this game:') - self.password = await self.console_input() - return self.password - - async def send_connect(self, **kwargs): - payload = { - 'cmd': 'Connect', - 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, - 'tags': self.tags, 'items_handling': self.items_handling, - 'uuid': Utils.get_unique_identifier(), 'game': self.game - } - if kwargs: - payload.update(kwargs) - await self.send_msgs([payload]) - - async def console_input(self): - self.input_requests += 1 - return await self.input_queue.get() - - async def connect(self, address=None): - await self.disconnect() - self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") - - def on_print(self, args: dict): - logger.info(args["text"]) - - def on_print_json(self, args: dict): - if self.ui: - self.ui.print_json(args["data"]) - else: - text = self.jsontotextparser(args["data"]) - logger.info(text) - - def on_package(self, cmd: str, args: dict): - pass - - def on_user_say(self, text: str) -> typing.Optional[str]: - """Gets called before sending a Say to the server from the user. - Returned text is sent, or sending is aborted if None is returned.""" - return text - - def update_permissions(self, permissions: typing.Dict[str, int]): - for permission_name, permission_flag in permissions.items(): - try: - flag = Permission(permission_flag) - logger.info(f"{permission_name.capitalize()} permission: {flag.name}") - self.permissions[permission_name] = flag.name - except Exception as e: # safeguard against permissions that may be implemented in the future - logger.exception(e) - async def shutdown(self): - self.server_address = None - if self.server and not self.server.socket.closed: - await self.server.socket.close() - if self.server_task: - await self.server_task - - while self.input_requests > 0: - self.input_queue.put_nowait(None) - self.input_requests -= 1 - self.keep_alive_task.cancel() + await super(ChecksFinderContext, self).shutdown() path = os.path.expandvars(r"%localappdata%/ChecksFinder") for root, dirs, files in os.walk(path): for file in files: if file.find("obtain") <= -1: os.remove(root+"/"+file) - # DeathLink hooks - - def on_deathlink(self, data: dict): - """Gets dispatched when a new DeathLink is triggered by another linked player.""" - self.last_death_link = max(data["time"], self.last_death_link) - text = data.get("cause", "") - if text: - logger.info(f"DeathLink: {text}") - else: - logger.info(f"DeathLink: Received from {data['source']}") - - async def send_death(self, death_text: str = ""): - if self.server and self.server.socket: - logger.info("DeathLink: Sending death to your friends...") - self.last_death_link = time.time() - await self.send_msgs([{ - "cmd": "Bounce", "tags": ["DeathLink"], - "data": { - "time": self.last_death_link, - "source": self.player_names[self.slot], - "cause": death_text - } - }]) - - async def update_death_link(self, death_link): - old_tags = self.tags.copy() - if death_link: - self.tags.add("DeathLink") - else: - self.tags -= {"DeathLink"} - if old_tags != self.tags and self.server and not self.server.socket.closed: - await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) - - -async def server_loop(ctx: CommonContext, address=None): - cached_address = None - if ctx.server and ctx.server.socket: - logger.error('Already connected') - return - - if address is None: # set through CLI or APBP - address = ctx.server_address - - # Wait for the user to provide a multiworld server address - if not address: - logger.info('Please connect to an Archipelago server.') - return - - address = f"ws://{address}" if "://" not in address else address - port = urllib.parse.urlparse(address).port or 38281 - logger.info(f'Connecting to Archipelago server at {address}') - try: - socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) - ctx.server = Endpoint(socket) - logger.info('Connected') - ctx.server_address = address - ctx.current_reconnect_delay = ctx.starting_reconnect_delay - async for data in ctx.server.socket: - for msg in decode(data): - await process_server_cmd(ctx, msg) - logger.warning('Disconnected from multiworld server, type /connect to reconnect') - except ConnectionRefusedError: - if cached_address: - logger.error('Unable to connect to multiworld server at cached address. ' - 'Please use the connect button above.') - else: - logger.exception('Connection refused by the multiworld server') - except websockets.InvalidURI: - logger.exception('Failed to connect to the multiworld server (invalid URI)') - except (OSError, websockets.InvalidURI): - logger.exception('Failed to connect to the multiworld server') - except Exception as e: - logger.exception('Lost connection to the multiworld server, type /connect to reconnect') - finally: - await ctx.connection_closed() - if ctx.server_address: - logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s") - asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect") - ctx.current_reconnect_delay *= 2 - - -async def process_server_cmd(ctx: CommonContext, args: dict): - try: - cmd = args["cmd"] - except: - logger.exception(f"Could not get command from {args}") - raise - if cmd == 'RoomInfo': - if ctx.seed_name and ctx.seed_name != args["seed_name"]: - logger.info("The server is running a different multiworld than your client is. (invalid seed_name)") - else: - logger.info('--------------------------------') - logger.info('Room Information:') - logger.info('--------------------------------') - version = args["version"] - ctx.server_version = tuple(version) - version = ".".join(str(item) for item in version) - - logger.info(f'Server protocol version: {version}') - logger.info("Server protocol tags: " + ", ".join(args["tags"])) - if args['password']: - logger.info('Password required') - ctx.update_permissions(args.get("permissions", {})) - if "games" in args: - ctx.games = {x: game for x, game in enumerate(args["games"], start=1)} - logger.info( - f"A !hint costs {args['hint_cost']}% of your total location count as points" - f" and you get {args['location_check_points']}" - f" for each location checked. Use !hint for more information.") - ctx.hint_cost = int(args['hint_cost']) - ctx.check_points = int(args['location_check_points']) - - if len(args['players']) < 1: - logger.info('No player connected') - else: - args['players'].sort() - current_team = -1 - logger.info('Connected Players:') - for network_player in args['players']: - if network_player.team != current_team: - logger.info(f' Team #{network_player.team + 1}') - current_team = network_player.team - logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) - if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0: - await ctx.send_msgs([{"cmd": "GetDataPackage"}]) - await ctx.server_auth(args['password']) - - elif cmd == 'DataPackage': - logger.info("Got new ID/Name Datapackage") - ctx.set_getters(args['data'], network=True) - - elif cmd == 'ConnectionRefused': - errors = args["errors"] - if 'InvalidSlot' in errors: - ctx.event_invalid_slot() - elif 'InvalidGame' in errors: - ctx.event_invalid_game() - elif 'SlotAlreadyTaken' in errors: - raise Exception('Player slot already in use for that team') - elif 'IncompatibleVersion' in errors: - raise Exception('Server reported your client version as incompatible') - elif 'InvalidItemsHandling' in errors: - raise Exception('The item handling flags requested by the client are not supported') - # last to check, recoverable problem - elif 'InvalidPassword' in errors: - logger.error('Invalid password') - ctx.password = None - await ctx.server_auth(True) - elif errors: - raise Exception("Unknown connection errors: " + str(errors)) - else: - raise Exception('Connection refused by the multiworld host, no reason provided') - - elif cmd == 'Connected': - if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")): - os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder")) - ctx.team = args["team"] - ctx.slot = args["slot"] - ctx.consume_players_package(args["players"]) - msgs = [] - if ctx.locations_checked: - msgs.append({"cmd": "LocationChecks", - "locations": list(ctx.locations_checked)}) - if ctx.locations_scouted: - msgs.append({"cmd": "LocationScouts", - "locations": list(ctx.locations_scouted)}) - if msgs: - await ctx.send_msgs(msgs) - if ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - - # Get the server side view of missing as of time of connecting. - # This list is used to only send to the server what is reported as ACTUALLY Missing. - # This also serves to allow an easy visual of what locations were already checked previously - # when /missing is used for the client side view of what is missing. - ctx.missing_locations = set(args["missing_locations"]) - ctx.checked_locations = set(args["checked_locations"]) - for ss in ctx.checked_locations: - filename = f"send{ss}" - with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f: - f.close() - - elif cmd == 'ReceivedItems': - start_index = args["index"] - - if start_index == 0: - ctx.items_received = [] - elif start_index != len(ctx.items_received): - sync_msg = [{'cmd': 'Sync'}] - if ctx.locations_checked: - sync_msg.append({"cmd": "LocationChecks", - "locations": list(ctx.locations_checked)}) - await ctx.send_msgs(sync_msg) - if start_index == len(ctx.items_received): - for item in args['items']: - filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item" - with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f: - f.write(str(NetworkItem(*item).item)) - f.close() - ctx.items_received.append(NetworkItem(*item)) - ctx.watcher_event.set() - - elif cmd == 'LocationInfo': - for item, location, player in args['locations']: - if location not in ctx.locations_info: - ctx.locations_info[location] = (item, player) - ctx.watcher_event.set() - - elif cmd == "RoomUpdate": - if "players" in args: - ctx.consume_players_package(args["players"]) - if "hint_points" in args: - ctx.hint_points = args['hint_points'] - if "checked_locations" in args: - checked = set(args["checked_locations"]) - ctx.checked_locations |= checked - ctx.missing_locations -= checked - for ss in ctx.checked_locations: + def on_package(self, cmd: str, args: dict): + if cmd in {"Connected"}: + if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")): + os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder")) + for ss in self.checked_locations: filename = f"send{ss}" - with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f: + with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f: f.close() - if "permissions" in args: - ctx.update_permissions(args["permissions"]) + if cmd in {"ReceivedItems"}: + start_index = args["index"] + if start_index != len(self.items_received): + for item in args['items']: + filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item" + with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f: + f.write(str(NetworkItem(*item).item)) + f.close() - elif cmd == 'Print': - ctx.on_print(args) + if cmd in {"RoomUpdate"}: + if "checked_locations" in args: + for ss in self.checked_locations: + filename = f"send{ss}" + with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f: + f.close() - elif cmd == 'PrintJSON': - ctx.on_print_json(args) + def run_gui(self): + """Import kivy UI system and start running it as self.ui_task.""" + from kvui import GameManager - elif cmd == 'InvalidPacket': - logger.warning(f"Invalid Packet of {args['type']}: {args['text']}") + class ChecksFinderManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago ChecksFinder Client" - elif cmd == "Bounced": - tags = args.get("tags", []) - # we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this - if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]: - ctx.on_deathlink(args["data"]) - elif cmd == "SetReply": - if args["key"] == "EnergyLink": - ctx.current_energy_link_value = args["value"] - if ctx.ui: - ctx.ui.set_new_energy_link_value() - else: - logger.debug(f"unknown command {cmd}") - - ctx.on_package(cmd, args) + self.ui = ChecksFinderManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") -async def game_watcher(ctx: CommonContext): +async def game_watcher(ctx: ChecksFinderContext): from worlds.checksfinder.Locations import lookup_id_to_name while not ctx.exit_event.is_set(): if ctx.syncing == True: @@ -600,38 +132,12 @@ async def game_watcher(ctx: CommonContext): if __name__ == '__main__': - # Text Mode to use !hint and such with games that have no text entry - - class TextContext(CommonContext): - game = "ChecksFinder" - items_handling = 0b111 # full remote - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(TextContext, self).server_auth(password_requested) - if not self.auth: - logger.info('Enter slot name:') - self.auth = await self.console_input() - - await self.send_connect() - - def on_package(self, cmd: str, args: dict): - if cmd == "Connected": - self.game = self.games.get(self.slot, None) - - async def main(args): - ctx = TextContext(args.connect, args.password) + ctx = ChecksFinderContext(args.connect, args.password) ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") - input_task = None if gui_enabled: - from kvui import ChecksFinderManager - ctx.ui = ChecksFinderManager(ctx) - ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI") - else: - ui_task = None - if sys.stdin: - input_task = asyncio.create_task(console_loop(ctx), name="Input") + ctx.run_gui() + ctx.run_cli() progression_watcher = asyncio.create_task( game_watcher(ctx), name="ChecksFinderProgressionWatcher") @@ -641,11 +147,6 @@ if __name__ == '__main__': await progression_watcher await ctx.shutdown() - if ui_task: - await ui_task - - if input_task: - input_task.cancel() import colorama @@ -653,8 +154,5 @@ if __name__ == '__main__': args, rest = parser.parse_known_args() colorama.init() - - loop = asyncio.get_event_loop() - loop.run_until_complete(main(args)) - loop.close() + asyncio.run(main(args)) colorama.deinit() diff --git a/kvui.py b/kvui.py index 7afd4363..f6e7a4b0 100644 --- a/kvui.py +++ b/kvui.py @@ -452,13 +452,6 @@ class GameManager(App): self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J" -class ChecksFinderManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago") - ] - base_title = "Archipelago ChecksFinder Client" - - class LogtoUI(logging.Handler): def __init__(self, on_log): super(LogtoUI, self).__init__(logging.INFO)