From b9fce5a33cf33918d483e8f1e5d503e006a6f9ea Mon Sep 17 00:00:00 2001 From: CookieCat Date: Tue, 19 Dec 2023 16:18:53 -0500 Subject: [PATCH] Fix client and leftover old options api --- AHITClient.py | 240 +--------------------------------- worlds/ahit/Client.py | 235 +++++++++++++++++++++++++++++++++ worlds/ahit/DeathWishRules.py | 1 - worlds/ahit/Items.py | 1 - worlds/ahit/Regions.py | 2 +- worlds/ahit/Rules.py | 6 +- worlds/ahit/__init__.py | 31 +++-- 7 files changed, 267 insertions(+), 249 deletions(-) create mode 100644 worlds/ahit/Client.py diff --git a/AHITClient.py b/AHITClient.py index 884f3ee5c7..6ed7d7b49d 100644 --- a/AHITClient.py +++ b/AHITClient.py @@ -1,236 +1,8 @@ -import asyncio +from worlds.ahit.Client import launch import Utils -import websockets -import functools -from copy import deepcopy -from typing import List, Any, Iterable -from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem -from MultiServer import Endpoint -from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser +import ModuleUpdate +ModuleUpdate.update() -DEBUG = False - - -class AHITJSONToTextParser(JSONtoTextParser): - def _handle_color(self, node: JSONMessagePart): - return self._handle_text(node) # No colors for the in-game text - - -class AHITCommandProcessor(ClientCommandProcessor): - def __init__(self, ctx: CommonContext): - super().__init__(ctx) - - def _cmd_ahit(self): - """Check AHIT Connection State""" - if isinstance(self.ctx, AHITContext): - logger.info(f"AHIT Status: {self.ctx.get_ahit_status()}") - - -class AHITContext(CommonContext): - command_processor = AHITCommandProcessor - game = "A Hat in Time" - - def __init__(self, server_address, password): - super().__init__(server_address, password) - self.proxy = None - self.proxy_task = None - self.gamejsontotext = AHITJSONToTextParser(self) - self.autoreconnect_task = None - self.endpoint = None - self.items_handling = 0b111 - self.room_info = None - self.connected_msg = None - self.game_connected = False - self.awaiting_info = False - self.full_inventory: List[Any] = [] - self.server_msgs: List[Any] = [] - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(AHITContext, self).server_auth(password_requested) - - await self.get_username() - await self.send_connect() - - def get_ahit_status(self) -> str: - if not self.is_proxy_connected(): - return "Not connected to A Hat in Time" - - return "Connected to A Hat in Time" - - async def send_msgs_proxy(self, msgs: Iterable[dict]) -> bool: - """ `msgs` JSON serializable """ - if not self.endpoint or not self.endpoint.socket.open or self.endpoint.socket.closed: - return False - - if DEBUG: - logger.info(f"Outgoing message: {msgs}") - - await self.endpoint.socket.send(msgs) - return True - - async def disconnect(self, allow_autoreconnect: bool = False): - await super().disconnect(allow_autoreconnect) - - async def disconnect_proxy(self): - if self.endpoint and not self.endpoint.socket.closed: - await self.endpoint.socket.close() - if self.proxy_task is not None: - await self.proxy_task - - def is_connected(self) -> bool: - return self.server and self.server.socket.open - - def is_proxy_connected(self) -> bool: - return self.endpoint and self.endpoint.socket.open - - def on_print_json(self, args: dict): - text = self.gamejsontotext(deepcopy(args["data"])) - msg = {"cmd": "PrintJSON", "data": [{"text": text}], "type": "Chat"} - self.server_msgs.append(encode([msg])) - - if self.ui: - self.ui.print_json(args["data"]) - else: - text = self.jsontotextparser(args["data"]) - logger.info(text) - - def update_items(self): - # just to be safe - we might still have an inventory from a different room - if not self.is_connected(): - return - - self.server_msgs.append(encode([{"cmd": "ReceivedItems", "index": 0, "items": self.full_inventory}])) - - def on_package(self, cmd: str, args: dict): - if cmd == "Connected": - self.connected_msg = encode([args]) - if self.awaiting_info: - self.server_msgs.append(self.room_info) - self.update_items() - self.awaiting_info = False - - elif cmd == "ReceivedItems": - if args["index"] == 0: - self.full_inventory.clear() - - for item in args["items"]: - self.full_inventory.append(NetworkItem(*item)) - - self.server_msgs.append(encode([args])) - - elif cmd == "RoomInfo": - self.seed_name = args["seed_name"] - self.room_info = encode([args]) - - else: - if cmd != "PrintJSON": - self.server_msgs.append(encode([args])) - - def run_gui(self): - from kvui import GameManager - - class AHITManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago") - ] - base_title = "Archipelago A Hat in Time Client" - - self.ui = AHITManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - - -async def proxy(websocket, path: str = "/", ctx: AHITContext = None): - ctx.endpoint = Endpoint(websocket) - try: - await on_client_connected(ctx) - - if ctx.is_proxy_connected(): - async for data in websocket: - if DEBUG: - logger.info(f"Incoming message: {data}") - - for msg in decode(data): - if msg["cmd"] == "Connect": - # Proxy is connecting, make sure it is valid - if msg["game"] != "A Hat in Time": - logger.info("Aborting proxy connection: game is not A Hat in Time") - await ctx.disconnect_proxy() - break - - if ctx.seed_name: - seed_name = msg.get("seed_name", "") - if seed_name != "" and seed_name != ctx.seed_name: - logger.info("Aborting proxy connection: seed mismatch from save file") - logger.info(f"Expected: {ctx.seed_name}, got: {seed_name}") - text = encode([{"cmd": "PrintJSON", - "data": [{"text": "Connection aborted - save file to seed mismatch"}]}]) - await ctx.send_msgs_proxy(text) - await ctx.disconnect_proxy() - break - - if ctx.connected_msg and ctx.is_connected(): - await ctx.send_msgs_proxy(ctx.connected_msg) - ctx.update_items() - continue - - if not ctx.is_proxy_connected(): - break - - await ctx.send_msgs([msg]) - - except Exception as e: - if not isinstance(e, websockets.WebSocketException): - logger.exception(e) - finally: - await ctx.disconnect_proxy() - - -async def on_client_connected(ctx: AHITContext): - if ctx.room_info and ctx.is_connected(): - await ctx.send_msgs_proxy(ctx.room_info) - else: - ctx.awaiting_info = True - - -async def main(): - parser = get_base_parser() - args = parser.parse_args() - - ctx = AHITContext(args.connect, args.password) - logger.info("Starting A Hat in Time proxy server") - ctx.proxy = websockets.serve(functools.partial(proxy, ctx=ctx), - host="localhost", port=11311, ping_timeout=999999, ping_interval=999999) - ctx.proxy_task = asyncio.create_task(proxy_loop(ctx), name="ProxyLoop") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - await ctx.proxy - await ctx.proxy_task - await ctx.exit_event.wait() - - -async def proxy_loop(ctx: AHITContext): - try: - while not ctx.exit_event.is_set(): - if len(ctx.server_msgs) > 0: - for msg in ctx.server_msgs: - await ctx.send_msgs_proxy(msg) - - ctx.server_msgs.clear() - await asyncio.sleep(0.1) - except Exception as e: - logger.exception(e) - logger.info("Aborting AHIT Proxy Client due to errors") - - -if __name__ == '__main__': - Utils.init_logging("AHITClient") - options = Utils.get_options() - - import colorama - colorama.init() - asyncio.run(main()) - colorama.deinit() +if __name__ == "__main__": + Utils.init_logging("AHITClient", exception_logger="Client") + launch() diff --git a/worlds/ahit/Client.py b/worlds/ahit/Client.py new file mode 100644 index 0000000000..f6f87a35a6 --- /dev/null +++ b/worlds/ahit/Client.py @@ -0,0 +1,235 @@ +import asyncio +import Utils +import websockets +import functools +from copy import deepcopy +from typing import List, Any, Iterable +from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem +from MultiServer import Endpoint +from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser + +DEBUG = False + + +class AHITJSONToTextParser(JSONtoTextParser): + def _handle_color(self, node: JSONMessagePart): + return self._handle_text(node) # No colors for the in-game text + + +class AHITCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx: CommonContext): + super().__init__(ctx) + + def _cmd_ahit(self): + """Check AHIT Connection State""" + if isinstance(self.ctx, AHITContext): + logger.info(f"AHIT Status: {self.ctx.get_ahit_status()}") + + +class AHITContext(CommonContext): + command_processor = AHITCommandProcessor + game = "A Hat in Time" + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.proxy = None + self.proxy_task = None + self.gamejsontotext = AHITJSONToTextParser(self) + self.autoreconnect_task = None + self.endpoint = None + self.items_handling = 0b111 + self.room_info = None + self.connected_msg = None + self.game_connected = False + self.awaiting_info = False + self.full_inventory: List[Any] = [] + self.server_msgs: List[Any] = [] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(AHITContext, self).server_auth(password_requested) + + await self.get_username() + await self.send_connect() + + def get_ahit_status(self) -> str: + if not self.is_proxy_connected(): + return "Not connected to A Hat in Time" + + return "Connected to A Hat in Time" + + async def send_msgs_proxy(self, msgs: Iterable[dict]) -> bool: + """ `msgs` JSON serializable """ + if not self.endpoint or not self.endpoint.socket.open or self.endpoint.socket.closed: + return False + + if DEBUG: + logger.info(f"Outgoing message: {msgs}") + + await self.endpoint.socket.send(msgs) + return True + + async def disconnect(self, allow_autoreconnect: bool = False): + await super().disconnect(allow_autoreconnect) + + async def disconnect_proxy(self): + if self.endpoint and not self.endpoint.socket.closed: + await self.endpoint.socket.close() + if self.proxy_task is not None: + await self.proxy_task + + def is_connected(self) -> bool: + return self.server and self.server.socket.open + + def is_proxy_connected(self) -> bool: + return self.endpoint and self.endpoint.socket.open + + def on_print_json(self, args: dict): + text = self.gamejsontotext(deepcopy(args["data"])) + msg = {"cmd": "PrintJSON", "data": [{"text": text}], "type": "Chat"} + self.server_msgs.append(encode([msg])) + + if self.ui: + self.ui.print_json(args["data"]) + else: + text = self.jsontotextparser(args["data"]) + logger.info(text) + + def update_items(self): + # just to be safe - we might still have an inventory from a different room + if not self.is_connected(): + return + + self.server_msgs.append(encode([{"cmd": "ReceivedItems", "index": 0, "items": self.full_inventory}])) + + def on_package(self, cmd: str, args: dict): + if cmd == "Connected": + self.connected_msg = encode([args]) + if self.awaiting_info: + self.server_msgs.append(self.room_info) + self.update_items() + self.awaiting_info = False + + elif cmd == "ReceivedItems": + if args["index"] == 0: + self.full_inventory.clear() + + for item in args["items"]: + self.full_inventory.append(NetworkItem(*item)) + + self.server_msgs.append(encode([args])) + + elif cmd == "RoomInfo": + self.seed_name = args["seed_name"] + self.room_info = encode([args]) + + else: + if cmd != "PrintJSON": + self.server_msgs.append(encode([args])) + + def run_gui(self): + from kvui import GameManager + + class AHITManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago A Hat in Time Client" + + self.ui = AHITManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + +async def proxy(websocket, path: str = "/", ctx: AHITContext = None): + ctx.endpoint = Endpoint(websocket) + try: + await on_client_connected(ctx) + + if ctx.is_proxy_connected(): + async for data in websocket: + if DEBUG: + logger.info(f"Incoming message: {data}") + + for msg in decode(data): + if msg["cmd"] == "Connect": + # Proxy is connecting, make sure it is valid + if msg["game"] != "A Hat in Time": + logger.info("Aborting proxy connection: game is not A Hat in Time") + await ctx.disconnect_proxy() + break + + if ctx.seed_name: + seed_name = msg.get("seed_name", "") + if seed_name != "" and seed_name != ctx.seed_name: + logger.info("Aborting proxy connection: seed mismatch from save file") + logger.info(f"Expected: {ctx.seed_name}, got: {seed_name}") + text = encode([{"cmd": "PrintJSON", + "data": [{"text": "Connection aborted - save file to seed mismatch"}]}]) + await ctx.send_msgs_proxy(text) + await ctx.disconnect_proxy() + break + + if ctx.connected_msg and ctx.is_connected(): + await ctx.send_msgs_proxy(ctx.connected_msg) + ctx.update_items() + continue + + if not ctx.is_proxy_connected(): + break + + await ctx.send_msgs([msg]) + + except Exception as e: + if not isinstance(e, websockets.WebSocketException): + logger.exception(e) + finally: + await ctx.disconnect_proxy() + + +async def on_client_connected(ctx: AHITContext): + if ctx.room_info and ctx.is_connected(): + await ctx.send_msgs_proxy(ctx.room_info) + else: + ctx.awaiting_info = True + + +async def proxy_loop(ctx: AHITContext): + try: + while not ctx.exit_event.is_set(): + if len(ctx.server_msgs) > 0: + for msg in ctx.server_msgs: + await ctx.send_msgs_proxy(msg) + + ctx.server_msgs.clear() + await asyncio.sleep(0.1) + except Exception as e: + logger.exception(e) + logger.info("Aborting AHIT Proxy Client due to errors") + + +def launch(): + async def main(): + parser = get_base_parser() + args = parser.parse_args() + + ctx = AHITContext(args.connect, args.password) + logger.info("Starting A Hat in Time proxy server") + ctx.proxy = websockets.serve(functools.partial(proxy, ctx=ctx), + host="localhost", port=11311, ping_timeout=999999, ping_interval=999999) + ctx.proxy_task = asyncio.create_task(proxy_loop(ctx), name="ProxyLoop") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + await ctx.proxy + await ctx.proxy_task + await ctx.exit_event.wait() + + Utils.init_logging("AHITClient") + # options = Utils.get_options() + + import colorama + colorama.init() + asyncio.run(main()) + colorama.deinit() diff --git a/worlds/ahit/DeathWishRules.py b/worlds/ahit/DeathWishRules.py index 3eb92b3dfe..90309bdb51 100644 --- a/worlds/ahit/DeathWishRules.py +++ b/worlds/ahit/DeathWishRules.py @@ -286,7 +286,6 @@ def get_total_dw_stamps(state: CollectionState, world: World) -> int: if state.has(f"2 Stamps - {name}", world.player): count += 2 elif name not in dw_candles: - # most non-candle bonus requirements allow the player to get the other stamp (like not having One Hit Hero) count += 1 return count diff --git a/worlds/ahit/Items.py b/worlds/ahit/Items.py index 3a13b3e3c8..6b0ccba7ea 100644 --- a/worlds/ahit/Items.py +++ b/worlds/ahit/Items.py @@ -89,7 +89,6 @@ def create_itempool(world: World) -> List[Item]: def calculate_yarn_costs(world: World): mw = world.multiworld - p = world.player min_yarn_cost = int(min(world.options.YarnCostMin.value, world.options.YarnCostMax.value)) max_yarn_cost = int(max(world.options.YarnCostMin.value, world.options.YarnCostMax.value)) diff --git a/worlds/ahit/Regions.py b/worlds/ahit/Regions.py index 6c0266bf44..7178075c5f 100644 --- a/worlds/ahit/Regions.py +++ b/worlds/ahit/Regions.py @@ -439,7 +439,7 @@ def create_rift_connections(world: World, region: Region): def create_tasksanity_locations(world: World): ship_shape: Region = world.multiworld.get_region("Ship Shape", world.player) id_start: int = TASKSANITY_START_ID - for i in range(world.multiworld.TasksanityCheckCount[world.player].value): + for i in range(world.options.TasksanityCheckCount.value): location = HatInTimeLocation(world.player, f"Tasksanity Check {i+1}", id_start+i, ship_shape) ship_shape.locations.append(location) diff --git a/worlds/ahit/Rules.py b/worlds/ahit/Rules.py index 1001645284..9fd7381779 100644 --- a/worlds/ahit/Rules.py +++ b/worlds/ahit/Rules.py @@ -322,7 +322,7 @@ def set_rules(world: World): and can_use_hookshot(state, world) and can_hit(state, world, True)) - difficulty: Difficulty = Difficulty(world.multiworld.LogicDifficulty[world.player].value) + difficulty: Difficulty = Difficulty(world.options.LogicDifficulty.value) if difficulty >= Difficulty.MODERATE: add_rule(loc, lambda state: state.has("TIHS Access", world.player) and can_use_hat(state, world, HatType.SPRINT), "or") @@ -534,7 +534,7 @@ def set_hard_rules(world: World): lambda state: can_use_hat(state, world, HatType.ICE)) # Hard: clear Rush Hour with Brewing Hat only - if world.multiworld.NoTicketSkips[world.player].value != 1: + if world.options.NoTicketSkips.value != 1: set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), lambda state: can_use_hat(state, world, HatType.BREWING)) else: @@ -610,7 +610,7 @@ def set_expert_rules(world: World): if world.is_dlc2(): # Expert: clear Rush Hour with nothing - if world.multiworld.NoTicketSkips[world.player].value == 0: + if world.options.NoTicketSkips.value == 0: set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), lambda state: True) else: set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), diff --git a/worlds/ahit/__init__.py b/worlds/ahit/__init__.py index 14eba116a6..f8527e95f4 100644 --- a/worlds/ahit/__init__.py +++ b/worlds/ahit/__init__.py @@ -10,9 +10,20 @@ from .DeathWishLocations import create_dw_regions, dw_classes, death_wishes from .DeathWishRules import set_dw_rules, create_enemy_events from worlds.AutoWorld import World, WebWorld from typing import List, Dict, TextIO -from worlds.LauncherComponents import Component, components, icon_paths +from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type from Utils import local_path + +def launch_client(): + from .Client import launch + launch_subprocess(launch, name="AHITClient") + + +components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client, + component_type=Type.CLIENT, icon='yatta')) + +icon_paths['yatta'] = local_path('data', 'yatta.png') + hat_craft_order: Dict[int, List[HatType]] = {} hat_yarn_costs: Dict[int, Dict[HatType, int]] = {} chapter_timepiece_costs: Dict[int, Dict[ChapterIndex, int]] = {} @@ -22,9 +33,6 @@ dw_shuffle: Dict[int, List[str]] = {} nyakuza_thug_items: Dict[int, Dict[str, int]] = {} badge_seller_count: Dict[int, int] = {} -components.append(Component("A Hat in Time Client", "AHITClient", icon='yatta')) -icon_paths['yatta'] = local_path('data', 'yatta.png') - class AWebInTime(WebWorld): theme = "partyTime" @@ -85,7 +93,8 @@ class HatInTimeWorld(World): nyakuza_thug_items[self.player] = {} badge_seller_count[self.player] = 0 self.shop_locs = [] - self.topology_present = self.options.ActRandomizer.value + # noinspection PyClassVar + self.topology_present = bool(self.options.ActRandomizer.value) create_regions(self) if self.options.EnableDeathWish.value > 0: @@ -231,7 +240,9 @@ class HatInTimeWorld(World): return new_hint_data = {} - alpine_regions = ["The Birdhouse", "The Lava Cake", "The Windmill", "The Twilight Bell", "Alpine Skyline Area"] + alpine_regions = ["The Birdhouse", "The Lava Cake", "The Windmill", + "The Twilight Bell", "Alpine Skyline Area", "Alpine Skyline Area (TIHS)"] + metro_regions = ["Yellow Overpass Station", "Green Clean Station", "Bluefin Tunnel", "Pink Paw Station"] for key, data in location_table.items(): @@ -245,6 +256,8 @@ class HatInTimeWorld(World): region_name = "Alpine Free Roam" elif data.region in metro_regions: region_name = "Nyakuza Free Roam" + elif "Dead Bird Studio - " in data.region: + region_name = "Dead Bird Studio" elif data.region in chapter_act_info.keys(): region_name = location.parent_region.name else: @@ -303,19 +316,19 @@ class HatInTimeWorld(World): def is_dw_excluded(self, name: str) -> bool: # don't exclude Seal the Deal if it's our goal if self.options.EndGoal.value == 3 and name == "Seal the Deal" \ - and f"{name} - Main Objective" not in self.multiworld.exclude_locations[self.player]: + and f"{name} - Main Objective" not in self.options.exclude_locations: return False if name in excluded_dws[self.player]: return True - return f"{name} - Main Objective" in self.multiworld.exclude_locations[self.player] + return f"{name} - Main Objective" in self.options.exclude_locations def is_bonus_excluded(self, name: str) -> bool: if self.is_dw_excluded(name) or name in excluded_bonuses[self.player]: return True - return f"{name} - All Clear" in self.multiworld.exclude_locations[self.player] + return f"{name} - All Clear" in self.options.exclude_locations def get_dw_shuffle(self): return dw_shuffle[self.player]