from __future__ import annotations import asyncio from functools import cache import os import re import sys import time import typing from typing import Any, Dict, List, Optional from enum import IntEnum import requests from worlds import AutoWorldRegister, network_data_package from worlds.LauncherComponents import icon_paths import json import traceback import ModuleUpdate ModuleUpdate.update() import Utils from NetUtils import ClientStatus from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, server_loop from MultiServer import mark_raw tracker_loaded = False try: from worlds.tracker.TrackerClient import TrackerGameContext as SuperContext, TrackerCommandProcessor ClientCommandProcessor = TrackerCommandProcessor tracker_loaded = True except ModuleNotFoundError: from CommonClient import CommonContext as SuperContext if typing.TYPE_CHECKING: import kvui class SortingOrderLoc(IntEnum): alphabetical = 1 inverted_alphabetical = -1 natural = 2 inverted_natural = -2 default = 2 # Docs must be done after because otherwise __doc__ return none SortingOrderLoc.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in locations.json." SortingOrderLoc.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" class SortingOrderItem(IntEnum): alphabetical = 1 inverted_alphabetical = -1 natural = 2 inverted_natural = -2 received = 3 inverted_received = -3 default = 3 SortingOrderItem.alphabetical.__doc__ = "Sort alphabetically using the name of item defined in items.json." SortingOrderItem.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" SortingOrderItem.received.__doc__ = "Sort the item in the order they are received from the server" class SortingOrderCategories(IntEnum): alphabetical = 1 inverted_alphabetical = -1 natural = 2 inverted_natural = -2 default = 2 SortingOrderCategories.alphabetical.__doc__ = "Sort alphabetically using the name of the category." SortingOrderCategories.natural.__doc__ = "Sort like alphabetically but makes sure that any number are read as integer and thus sorted naturally. EG. key2 < key12" @cache def strip_articles(title: str) -> str: lower = title.lower() if lower.startswith("the "): title = title[4:] elif lower.startswith("a "): title = title[2:] elif lower.startswith("an "): title = title[3:] return title def natural_sort_key(key: str): # Modified from https://stackoverflow.com/a/11150413 def convert(text): return int(text) if text.isdigit() else text.lower() key = strip_articles(key) return [convert(c) for c in re.split('([0-9]+)', key)] class ManualClientCommandProcessor(ClientCommandProcessor): ctx: ManualContext def _cmd_resync(self) -> bool: """Manually trigger a resync.""" self.output("Syncing items.") self.ctx.syncing = True return True @mark_raw def _cmd_send(self, location_name: str) -> bool: """Send a check""" names = self.ctx.location_names_to_id.keys() location_name, usable, response = Utils.get_intended_text( location_name, names ) if usable: location_id = self.ctx.location_names_to_id[location_name] self.ctx.locations_checked.append(location_id) self.ctx.syncing = True return True else: self.output(response) return False @mark_raw def _cmd_open_settings(self) -> bool: """Open the settings panel.""" if gui_enabled: self.ctx.ui.open_settings() return True else: self.output("GUI is not enabled.") return False class ManualContext(SuperContext): command_processor = ManualClientCommandProcessor game = None # this is changed in server_auth below based on user input items_handling = 0b111 # full remote tags = {"AP"} location_table = {} item_table = {} region_table = {} category_table = {} tracker_reachable_locations: list[str] = [] tracker_glitched_locations: list[str] = [] tracker_reachable_events: list[str] = [] set_deathlink = False last_death_link = 0 deathlink_out = False goaled = False visible_events: dict[str, dict[str, Any]] = {} search_term = "" items_sorting = SortingOrderItem.default.name items_sorting_uses_sortkeys = True item_categories_sorting = SortingOrderCategories.default.name item_categories_sorting_uses_sortkeys = True locations_sorting = SortingOrderLoc.default.name locations_sorting_uses_sortkeys = True location_categories_sorting = SortingOrderCategories.default.name location_categories_sorting_uses_sortkeys = True block_unreachable_location_press = True display_glitched_locations = True allow_glitched_location_press = True colors = { 'location_default': [219/255, 218/255, 213/255, 1], 'location_in_logic': [2/255, 242/255, 42/255, 1], 'location_in_glitched_logic': [247/255, 255/255, 119/255, 1], 'category_even_default': [0.5, 0.5, 0.5, 0.1], 'category_odd_default': [1.0, 1.0, 1.0, 0.0], 'category_in_logic': [2/255, 82/255, 2/255, 1], 'category_in_glitched_logic': [172/255, 179/255, 81/255, 1], 'deathlink_received': [1, 0, 0, 1], 'deathlink_primed': [1, 1, 1, 1], 'deathlink_sent': [0, 1, 0, 1], 'game_select_button': [200/255, 200/255, 200/255, 1], 'header_background': [15/255, 80/255, 112/255, 1] } def __init__(self, server_address, password, game, player_name) -> None: super(ManualContext, self).__init__(server_address, password) if tracker_loaded: super().set_callback(self.on_tracker_updated) # Universal Tracker takes this func and calls it when updateTracker is called if hasattr(self, "set_events_callback"): super().set_events_callback(self.on_tracker_events) # Universal Tracker takes this func and calls it when events are calculated if hasattr(self, "set_glitches_callback"): super().set_glitches_callback(self.on_glitches_call) # Universal Tracker takes this func and calls it when OOL glitched location are calculated self.send_index: int = 0 self.syncing = False self.game = game self.username = player_name self.locations_checked: list[int] = [] self.locations_scouted: list[int] = [] async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: if tracker_loaded: await super(SuperContext, self).server_auth(password_requested) # type: ignore else: await super(ManualContext, self).server_auth(password_requested) if "Manual_" not in self.ui.game_bar_text.text: raise Exception("The Manual client can only be used for Manual games.") self.game = self.ui.game_bar_text.text self.goaled = False world = AutoWorldRegister.world_types.get(self.game) if not self.location_table and not self.item_table and world is None: raise Exception(f"Cannot load {self.game}, please add the apworld to custom_worlds/") data_package = network_data_package["games"].get(self.game, {}) self.update_ids(data_package) if world is not None and hasattr(world, "victory_names"): self.victory_names = world.victory_names self.goal_location = self.get_location_by_name(world.victory_names[0]) else: self.victory_names = ["__Manual Game Complete__"] self.goal_location = self.get_location_by_name("__Manual Game Complete__") await self.get_username() await self.send_connect() async def connection_closed(self): await super(ManualContext, self).connection_closed() @property def suggested_game(self) -> str: if self.game: return self.game from .Game import game_name # This will at least give us the name of a manual they've installed return Utils.persistent_load().get("client", {}).get("last_manual_game", None) or game_name def get_location_by_name(self, name) -> dict[str, Any]: location = self.location_table.get(name) if not location: # It is absolutely possible to pull categories from the data_package via self.update_game. I have not done this yet. location = AutoWorldRegister.world_types[self.game].location_name_to_location.get(name, {"name": name}) # type: ignore return location def get_location_by_id(self, id) -> dict[str, Any]: name = self.location_names.lookup_in_game(id) return self.get_location_by_name(name) def get_item_by_name(self, name): item = self.item_table.get(name) if not item: item = AutoWorldRegister.world_types[self.game].item_name_to_item.get(name, {"name": name}) # type: ignore return item def get_item_by_id(self, id): name = self.item_names.lookup_in_game(id) return self.get_item_by_name(name) def get_category_by_name( self, name:str) -> dict[str, Any]: category = self.category_table.get(name) if not category: category = AutoWorldRegister.world_types[self.game].category_table.get(name, {"name": name}) # type: ignore return category def get_sorting_key_by_id(self, key, is_item=True) -> str: Manual_dict: Dict[str, str] = self.get_item_by_id(key) if is_item else self.get_location_by_id(key) name: str | None = None if (is_item and self.items_sorting_uses_sortkeys) or \ (not is_item and self.locations_sorting_uses_sortkeys): name = Manual_dict.get("sort-key") name = Manual_dict["name"] if name is None else name name = strip_articles(name.lower()) return name def get_category_sorting_key_by_name(self, name, is_item = True) -> str: Manual_dict: Dict[str, Any] = self.get_category_by_name(name) key: str | None = None if (is_item and self.item_categories_sorting_uses_sortkeys) or\ (not is_item and self.location_categories_sorting_uses_sortkeys): key = Manual_dict.get("sort-key") key = name if key is None else key key = strip_articles(key.lower()) return key def update_ids(self, data_package) -> None: self.location_names_to_id = data_package['location_name_to_id'] self.item_names_to_id = data_package['item_name_to_id'] def update_data_package(self, data_package: dict): super().update_data_package(data_package) for game, game_data in data_package["games"].items(): if game == self.game: self.update_ids(game_data) def set_search(self, search_term: str): self.search_term = search_term def clear_search(self): self.search_term = "" @property def endpoints(self): if self.server: return [self.server] else: return [] async def shutdown(self): await super(ManualContext, self).shutdown() def on_package(self, cmd: str, args: dict): super().on_package(cmd, args) if cmd in {"Connected", "DataPackage"}: if cmd == "Connected": Utils.persistent_store("client", "last_manual_game", self.game) if args.get("slot_data"): goal = args["slot_data"].get("goal") if goal and goal < len(self.victory_names): self.goal_location = self.get_location_by_name(self.victory_names[goal]) if args['slot_data'].get('death_link'): self.ui.enable_death_link() self.set_deathlink = True self.last_death_link = 0 self.visible_events = args['slot_data'].get('visible_events', {}) logger.info(f"Slot data: {args['slot_data']}") self.ui.build_tracker_and_locations_table() self.ui.request_update_tracker_and_locations_table(update_highlights=True) elif cmd in {"ReceivedItems"}: self.ui.request_update_tracker_and_locations_table(update_highlights=True) elif cmd in {"RoomUpdate"}: self.ui.request_update_tracker_and_locations_table(update_highlights=False) def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: super().on_deathlink(data) self.ui.death_link_button.text = f"Death Link: {data['source']}" self.ui.death_link_button.background_color = self.colors['deathlink_received'] def on_tracker_updated(self, reachable_locations: list[str]): self.tracker_reachable_locations = reachable_locations self.ui.request_update_tracker_and_locations_table(update_highlights=True) def on_tracker_events(self, events: list[str]): self.tracker_reachable_events = events if events: self.ui.request_update_tracker_and_locations_table(update_highlights=True) def on_glitches_call(self, glitched_locations: list[str]): self.tracker_glitched_locations = glitched_locations if glitched_locations: self.ui.request_update_tracker_and_locations_table(update_highlights=True) def is_event_visible(self, event_name, category_name): if event_name not in self.visible_events: return False if category_name == "(No Category)" and len(self.visible_events[event_name]) == 0: return True return category_name in self.visible_events[event_name] def handle_connection_loss(self, msg: str) -> None: """Helper for logging and displaying a loss of connection. Must be called from an except block.""" exc_info = sys.exc_info() logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) tracker_error = False e = exc_info[2] formatted_tb = ''.join(traceback.format_tb(e)) while e: if '/tracker/' in e.tb_frame.f_code.co_filename: tracker_error = True break e = e.tb_next if tracker_error: self._messagebox_connection_loss = self.gui_error( "A Universal Tracker error has occurred. Please ensure that your version of UT matches your version of Archipelago.", formatted_tb) else: self._messagebox_connection_loss = self.gui_error(msg, formatted_tb) def run_gui(self): """Import kivy UI system from make_gui() and start running it as self.ui_task.""" if hasattr(SuperContext, "make_gui"): # Call the real one if it exists return super().run_gui() # This is a copy of 0.5.1's run_gui, because backporting is easier than the alternative. # This entire function can be removed once 0.5.1 is the old enough. ui_class = self.make_gui() self.ui = ui_class(self) self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") def make_gui(self) -> typing.Type["kvui.GameManager"]: if hasattr(SuperContext, "make_gui"): ui = super().make_gui() # before the kivy imports so kvui gets loaded first else: from kvui import GameManager ui = GameManager from kivy.core.window import Window from kivy.lang import Builder from kivy.metrics import dp from kivy.properties import ColorProperty from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.dropdown import DropDown from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.layout import Layout from kivy.uix.scrollview import ScrollView from kivy.uix.settings import Settings from kivy.uix.spinner import Spinner, SpinnerOption from kivy.uix.textinput import TextInput from kivy.uix.treeview import TreeView, TreeViewLabel, TreeViewNode from kivy.config import ConfigParser class ManualTabLayout(BoxLayout): pass class ManualControlsLayout(BoxLayout): pass class TrackerAndLocationsLayout(GridLayout): pass class TrackerLayoutScrollable(ScrollView): pass class LocationsLayoutScrollable(ScrollView): pass class TreeViewButton(Button, TreeViewNode): victory: bool = False id: int|None = None location_name: str = "" class ItemLabel(Label): item_id: int|None = None item_count: int = 1 item_name: str = "" class TreeViewScrollView(ScrollView, TreeViewNode): pass class GameSelectOption(SpinnerOption): background_color = self.colors['game_select_button'] class GameSelectDropDown(DropDown): # If someone can figure out how to give this a solid background, I'd be very happy. pass Builder.load_string( """ : canvas: Color: rgba: root.background_color Rectangle: pos: self.pos size: self.size """) class ManualControlsStyledLayout(BoxLayout): background_color = ColorProperty() class ManualManager(ui): base_title = "Archipelago Manual Client" listed_items = {"(No Category)": []} item_categories = ["(No Category)"] listed_locations = {"(No Category)": []} location_categories = ["(No Category)"] active_item_accordion = 0 active_location_accordion = 0 update_requested_time: Optional[float] = None update_requested_highlights: bool = False mouse_pos: tuple ctx: ManualContext def __init__(self, ctx): super().__init__(ctx) def build(self) -> Layout: super().build() self.ctx.items_sorting = self.config.get('manual', 'items_sorting_order') self.ctx.items_sorting_uses_sortkeys = self.config.get('manual', 'items_sorting_uses_sortkeys') self.ctx.locations_sorting = self.config.get('manual', 'locations_sorting_order') self.ctx.locations_sorting_uses_sortkeys = self.config.get('manual', 'locations_sorting_uses_sortkeys') self.ctx.item_categories_sorting = self.config.get('manual', 'item_categories_sorting_order') self.ctx.item_categories_sorting_uses_sortkeys = self.config.get('manual', 'item_categories_sorting_uses_sortkeys') self.ctx.location_categories_sorting = self.config.get('manual', 'location_categories_sorting_order') self.ctx.location_categories_sorting_uses_sortkeys = self.config.get('manual', 'location_categories_sorting_uses_sortkeys') self.ctx.block_unreachable_location_press = True if self.config.get('universal-tracker', 'block_unreachable_location_press') == "Yes" else False self.ctx.display_glitched_locations = True if self.config.get('universal-tracker', 'display_glitched_locations') == "Yes" else False self.ctx.allow_glitched_location_press = True if self.config.get('universal-tracker', 'allow_glitched_location_press') == "Yes" else False self.manual_game_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) game_bar_label = Label(text="Manual Game ID", size=(dp(150), dp(30)), size_hint_y=None, size_hint_x=None) manuals = [w for w in AutoWorldRegister.world_types.keys() if "Manual_" in w] manuals.sort() # Sort by alphabetical order, not load order self.manual_game_layout.add_widget(game_bar_label) self.game_bar_text = Spinner(text=self.ctx.suggested_game, size_hint_y=None, height=dp(30), sync_height=True, values=manuals, option_cls=GameSelectOption, dropdown_cls=GameSelectDropDown) self.manual_game_layout.add_widget(self.game_bar_text) self.grid.add_widget(self.manual_game_layout, 3) panel = self.add_client_tab("Manual", ManualTabLayout(orientation="vertical")) self.controls_panel = ManualControlsLayout(orientation="horizontal", size_hint_y=None, height=dp(40)) self.tracker_and_locations_panel = TrackerAndLocationsLayout(cols = 2) panel.content.add_widget(self.controls_panel) panel.content.add_widget(self.tracker_and_locations_panel) self.build_tracker_and_locations_table() return self.container def get_application_config(self, defaultpath: str = "") -> str: return Utils.user_path("manual_client.ini") def build_config(self, config: ConfigParser): super().build_config(config) config.setdefaults("manual", { "items_sorting_order": SortingOrderItem.default.name, "items_sorting_uses_sortkeys": "Yes", "locations_sorting_order": SortingOrderLoc.default.name, "locations_sorting_uses_sortkeys": "Yes", "item_categories_sorting_order": SortingOrderCategories.default.name, "item_categories_sorting_uses_sortkeys": "Yes", "location_categories_sorting_order": SortingOrderCategories.default.name, "location_categories_sorting_uses_sortkeys": "Yes" }) config.setdefaults("universal-tracker", { "block_unreachable_location_press": "Yes", "display_glitched_locations": "Yes", "allow_glitched_location_press": "Yes" }) def build_settings(self, settings: Settings): super().build_settings(settings) json_data = [ { "type": "title", "title": "Manual Client" }, { "type": "options", "title": "Items Sorting Order", "section": "manual", "key": "items_sorting_order", "options": list(SortingOrderItem._member_names_), "desc": '\n'.join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderItem if i.__doc__ is not None]) }, { "type": "bool", "title": "Items Sorting uses sortkeys", "desc": "Should Item sorting respect sort-keys", "section": "manual", "key": "items_sorting_uses_sortkeys", "values": ["No", "Yes"] }, { "type": "options", "title": "Locations Sorting Order", "section": "manual", "key": "locations_sorting_order", "options": list(SortingOrderLoc._member_names_), "desc": "\n".join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderLoc if i.__doc__ is not None]) }, { "type": "bool", "title": "Locations Sorting uses sortkeys", "desc": "Should Location sorting respect sort-keys", "section": "manual", "key": "locations_sorting_uses_sortkeys", "values": ["No", "Yes"] }, { "type": "options", "title": "Item Categories Sorting Order", "section": "manual", "key": "item_categories_sorting_order", "options": list(SortingOrderCategories._member_names_), "desc": '\n'.join([f'[b]{i.name}/inverted_{i.name}[/b]: {i.__doc__}' for i in SortingOrderCategories if i.__doc__ is not None]) }, { "type": "bool", "title": "Item Categories Sorting uses sortkeys", "desc": "Should Item Category sorting respect sort-keys", "section": "manual", "key": "item_categories_sorting_uses_sortkeys", "values": ["No", "Yes"] }, { "type": "options", "title": "Location Categories Sorting Order", "section": "manual", "key": "location_categories_sorting_order", "options": list(SortingOrderCategories._member_names_), "desc": "Same Options as Items Category Sorting Order." }, { "type": "bool", "title": "Location Categories Sorting uses sortkeys", "desc": "Should Location sorting respect sort-keys", "section": "manual", "key": "location_categories_sorting_uses_sortkeys", "values": ["No", "Yes"] }, ] if tracker_loaded: json_data.extend([ { "type": "title", "title": "Universal Tracker Compatibility" }, { "type": "bool", "title": "Stop accidental button press", "section": "universal-tracker", "key": "block_unreachable_location_press", "desc": "Should only reachable location be able to be pressed", "values": ["No", "Yes"] }, { "type": "bool", "title": "See Glitched Logic", "section": "universal-tracker", "key": "display_glitched_locations", "desc": "Should locations marked as accessible OOL be shown in Yellow", "values": ["No", "Yes"] }, { "type": "bool", "title": "Count glitched location as reachable for the press protection", "section": "universal-tracker", "key": "allow_glitched_location_press", "desc": "Should locations marked as accessible OOL not be protected from being pressed", "values": ["No", "Yes"] }, ]) settings.add_json_panel("Manual Client Settings", self.config, data=json.dumps(json_data)) def on_config_change(self, config, section, key, value): super().on_config_change(config, section, key, value) if section == "manual": if key == "items_sorting_order": if value in SortingOrderItem._member_names_: self.ctx.items_sorting = value self.request_update_tracker_and_locations_table() elif key == "items_sorting_uses_sortkeys": self.ctx.items_sorting_uses_sortkeys = True if value == "Yes" else False self.request_update_tracker_and_locations_table() elif key == "locations_sorting_order": if value in SortingOrderLoc._member_names_: self.ctx.locations_sorting = value self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif key == "locations_sorting_uses_sortkeys": self.ctx.locations_sorting_uses_sortkeys = True if value == "Yes" else False self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif key == "location_categories_sorting_order": if value in SortingOrderCategories._member_names_: self.ctx.location_categories_sorting = value self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif key == "location_categories_sorting_uses_sortkeys": self.ctx.location_categories_sorting_uses_sortkeys = True if value == "Yes" else False self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif key == "item_categories_sorting_order": if value in SortingOrderCategories._member_names_: self.ctx.item_categories_sorting = value self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif key == "item_categories_sorting_uses_sortkeys": self.ctx.item_categories_sorting_uses_sortkeys = True if value == "Yes" else False self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif section == "universal-tracker": if key == "block_unreachable_location_press": self.ctx.block_unreachable_location_press = True if value == "Yes" else False elif key == "display_glitched_locations": self.ctx.display_glitched_locations = True if value == "Yes" else False self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() elif key == "allow_glitched_location_press": self.ctx.allow_glitched_location_press = True if value == "Yes" else False def clear_lists(self): self.listed_items = {"(No Category)": []} self.item_categories = ["(No Category)"] self.listed_locations: Dict[str, List[int]] = {"(No Category)": [], "(Hinted)": []} self.location_categories = ["(No Category)", "(Hinted)"] def set_active_item_accordion(self, instance): index = 0 for widget in self.children: if widget == instance: self.active_item_accordion = index return index += 1 def set_active_location_accordion(self, instance): index = 0 for widget in self.children: if widget == instance: self.active_item_accordion = index return index += 1 def enable_death_link(self): if not hasattr(self, "death_link_button"): self.death_link_button = Button(text="Death Link: Primed", size_hint_x=None, width=150) self.connect_layout.add_widget(self.death_link_button) self.death_link_button.bind(on_release=self.send_death_link) def send_death_link(self, *args): if self.ctx.last_death_link: self.ctx.last_death_link = 0 self.death_link_button.text = "Death Link: Primed" self.death_link_button.background_color = self.ctx.colors['deathlink_primed'] else: self.ctx.deathlink_out = True self.death_link_button.text = "Death Link: Sent" self.death_link_button.background_color = self.ctx.colors['deathlink_sent'] def update_hints(self): super().update_hints() rebuild = False for hint in self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", []): if hint["finding_player"] == self.ctx.slot: if hint["location"] in self.ctx.missing_locations: location = self.ctx.get_location_by_id(hint["location"]) location["category"] = location.get("category", []) if "(Hinted)" not in location["category"]: location["category"].append("(Hinted)") rebuild = True json_str = [ {"type": "player_id", "text": hint["receiving_player"]}, {"type": "text", "text": "'s "}, {"type": "item_id", "text": hint["item"], "flags": hint["item_flags"], "player": hint["receiving_player"], }, ] location['hint_text'] = self.json_to_kivy_parser(json_str) if rebuild: self.build_tracker_and_locations_table() self.request_update_tracker_and_locations_table() def update_search_from_input(self, instance, text: str): self.ctx.set_search(text) self.request_update_tracker_and_locations_table() # if we want search to be "snappier", we can just make this update def clear_search_input(self): self.search_textbox.text = "" self.ctx.clear_search() self.request_update_tracker_and_locations_table() # if we want search to be "snappier", we can just make this update def window_mouseover(self, window, pos): self.set_mouse_pos(window, pos) def set_mouse_pos(self, window, pos): self.mouse_pos = pos def are_top_controls_at_mouse_pos(self) -> bool: # check server connect section and controls if self.connect_layout.collide_point(*self.mouse_pos): return True # check game id section and dropdown if self.manual_game_layout.collide_point(*self.mouse_pos): return True # check tabbed navigation if self.tabs.collide_point(*self.mouse_pos): return True return False def get_top_obj_at_mouse_pos(self) -> Any: for child in self.connect_layout.children: if child.collide_point(*self.mouse_pos): return child for child in self.manual_game_layout.children: if child.collide_point(*self.mouse_pos): return child for child in self.tabs.children: if child.collide_point(*self.mouse_pos): return child return None def build_tracker_and_locations_table(self): Window.bind(mouse_pos=self.window_mouseover) self.controls_panel.clear_widgets() self.tracker_and_locations_panel.clear_widgets() if not self.ctx.server or not self.ctx.auth: self.tracker_and_locations_panel.add_widget( Label(text="Waiting for connection...", size_hint_y=None, height=50, outline_width=1)) return self.clear_lists() # build tab-specific controls above the two tracker columns controls_styled_layout = ManualControlsStyledLayout(orientation="horizontal", size_hint_y=None, height=dp(40), padding=dp(5), background_color=self.ctx.colors["header_background"]) search_layout = BoxLayout(orientation="horizontal", size_hint=(None, None), width=dp(320), height=dp(30), spacing=dp(2)) search_label = Label(text="Search:", size_hint=(None, None), width=dp(55), height=dp(30), bold=True) self.search_textbox = TextInput(size_hint=(None, None), width=dp(200), height=dp(30), multiline=False, write_tab=False) self.search_textbox.bind(text = self.update_search_from_input) search_button = Button(size_hint=(None, None), width=dp(50), height=dp(30), text="Clear") search_button.bind(on_release=lambda *args: self.clear_search_input()) controls_styled_layout.add_widget(search_layout) search_layout.add_widget(search_label) search_layout.add_widget(self.search_textbox) search_layout.add_widget(search_button) self.controls_panel.add_widget(controls_styled_layout) # seed all category names to start for item in self.ctx.item_table.values() or AutoWorldRegister.world_types[self.ctx.game].item_name_to_item.values(): if "category" in item and len(item["category"]) > 0: for category in item["category"]: category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {}) if "hidden" in category_settings and category_settings["hidden"]: continue if category not in self.item_categories: self.item_categories.append(category) if category not in self.listed_items: self.listed_items[category] = [] for event, categories in self.ctx.visible_events.items(): for category in categories: category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {}) if "hidden" in category_settings and category_settings["hidden"]: continue if category not in self.item_categories: self.item_categories.append(category) if category not in self.listed_items: self.listed_items[category] = [] # Items are not received on connect, so don't bother attempting to work with received items here if not self.ctx.location_table and not hasattr(AutoWorldRegister.world_types[self.ctx.game], 'location_name_to_location'): raise Exception("The apworld for %s is too outdated for this client. Please update it." % (self.ctx.game)) scoutable_locations = set() hinted_locations = {} for location_id in self.ctx.missing_locations: # holy nesting, wow location_name = self.ctx.location_names.lookup_in_game(location_id) location = self.ctx.get_location_by_name(location_name) if not location: continue if location.get("scoutable", False): scoutable_locations.add(location_id) if location.get("hint_text", False): hinted_locations[location_id] = location.get("hint_text", "") if "category" in location and len(location["category"]) > 0: for category in location["category"]: category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {}) if "hidden" in category_settings and category_settings["hidden"]: continue if category not in self.location_categories: self.location_categories.append(category) if category not in self.listed_locations: self.listed_locations[category] = [] self.listed_locations[category].append(location_id) else: # leave it in the generic category self.listed_locations["(No Category)"].append(location_id) victory_location = self.ctx.goal_location victory_categories = set(victory_location.get("category", [])) for category in victory_categories: if category not in self.location_categories: self.location_categories.append(category) if category not in self.listed_locations: self.listed_locations[category] = [] if not victory_categories: victory_categories.add("(No Category)") loc_sorting = SortingOrderLoc[self.ctx.locations_sorting] def get_location_key(key: int) -> str: return self.ctx.get_sorting_key_by_id(key, is_item=False) if abs(loc_sorting) == SortingOrderLoc.alphabetical: for category in self.listed_locations: self.listed_locations[category].sort(key=get_location_key, reverse=loc_sorting < 0) elif abs(loc_sorting) == SortingOrderLoc.natural: def alphanum_key(key: int) -> list[str|int]: return natural_sort_key(get_location_key(key)) for category in self.listed_locations: self.listed_locations[category].sort(key=alphanum_key, reverse=loc_sorting < 0) items_length = len(self.ctx.items_received) tracker_panel_scrollable = TrackerLayoutScrollable(do_scroll=(False, True), bar_width=10) tracker_panel = TreeView(root_options=dict(text="Items Received (%d)" % (items_length)), size_hint_y=None) tracker_panel.bind(minimum_height=tracker_panel.setter('height')) def get_item_category_key(key: str) -> str: return self.ctx.get_category_sorting_key_by_name(key) def item_category_normal_sort_key(key: str): result = [get_item_category_key(key)] return [0 if key.lstrip().startswith("(") else 1] + result def item_category_nat_sort_key(key: str): result = natural_sort_key(get_item_category_key(key)) return [0 if key.lstrip().startswith("(") else 1] + result # Sorting items categories item_cat_sorting = SortingOrderCategories[self.ctx.item_categories_sorting] if abs(item_cat_sorting) == SortingOrderCategories.natural: self.listed_items = {key: self.listed_items[key] for key in sorted(self.listed_items.keys(), key=item_category_nat_sort_key, reverse=item_cat_sorting < 0)} else: self.listed_items = {key: self.listed_items[key] for key in sorted(self.listed_items.keys(), key=item_category_normal_sort_key, reverse=item_cat_sorting < 0)} # Since items_received is not available on connect, don't bother building item labels here for item_category in self.listed_items.keys(): category_tree = tracker_panel.add_node( TreeViewLabel(text = "%s (%s)" % (item_category, len(self.listed_items[item_category]))) ) category_scroll = tracker_panel.add_node(TreeViewScrollView(size_hint=(1, None), size=(Window.width / 2, 250)), category_tree) category_layout = GridLayout(cols=1, size_hint_y=None) category_layout.bind(minimum_height = category_layout.setter('height')) category_scroll.add_widget(category_layout) locations_length = len(self.ctx.missing_locations) locations_panel_scrollable = LocationsLayoutScrollable(do_scroll=(False, True), bar_width=10) locations_panel = TreeView(root_options=dict(text="Remaining Locations (%d)" % (locations_length + 1)), size_hint_y=None) locations_panel.bind(minimum_height=locations_panel.setter('height')) # This seems like a redundant copy of the same check above? if not self.ctx.location_table and not hasattr(AutoWorldRegister.world_types[self.ctx.game], 'location_name_to_location'): raise Exception("The apworld for %s is too outdated for this client. Please update it." % (self.ctx.game)) # Sorting location categories loc_cat_sorting = SortingOrderCategories[self.ctx.location_categories_sorting] def get_location_category_key(key: str) -> str: return self.ctx.get_category_sorting_key_by_name(key, is_item=False) def loc_category_normal_sort_key(key: str): result = [get_location_category_key(key)] return [0 if key.lstrip().startswith("(") else 1] + result def loc_category_nat_sort_key(key: str): result = natural_sort_key(get_location_category_key(key)) return [0 if key.lstrip().startswith("(") else 1] + result if abs(loc_cat_sorting) == SortingOrderCategories.natural: self.listed_locations = {key: self.listed_locations[key] for key in sorted(self.listed_locations.keys(), key=loc_category_nat_sort_key, reverse=loc_cat_sorting < 0)} else: self.listed_locations = {key: self.listed_locations[key] for key in sorted(self.listed_locations.keys(), key=loc_category_normal_sort_key, reverse=loc_cat_sorting < 0)} for location_category in self.listed_locations.keys(): locations_in_category = len(self.listed_locations[location_category]) if (location_category in victory_categories) or \ (not victory_categories and location_category == "(No Category)"): locations_in_category += 1 category_tree = locations_panel.add_node( TreeViewLabel(text = "%s (%s)" % (location_category, locations_in_category)) ) category_scroll = locations_panel.add_node(TreeViewScrollView(size_hint=(1, None), size=(Window.width / 2, 250)), category_tree) category_layout = GridLayout(cols=2, size_hint_y=None) category_layout.bind(minimum_height = category_layout.setter('height')) category_scroll.add_widget(category_layout) for location_id in self.listed_locations[location_category]: has_hint = location_id in hinted_locations or location_id in scoutable_locations text = f"{self.ctx.location_names.lookup_in_game(location_id)}" location_button = TreeViewButton(text=text, size_hint=(.75 if has_hint else 1, None), height=30) location_button.bind(on_release=lambda *args, loc_id=location_id: self.location_button_callback(loc_id, *args)) location_button.id = location_id location_button.location_name = self.ctx.location_names.lookup_in_game(location_id) category_layout.add_widget(location_button) if location_id in hinted_locations: category_layout.add_widget(Label(text=hinted_locations[location_id], size_hint=(.25, None), height=30, font_size=10, markup=True)) elif location_id in scoutable_locations: location_scout = TreeViewButton(text="Scout", size_hint=(.25, None), height=30, font_size=10) location_scout.bind(on_release=lambda *args, loc_id=location_id: self.location_scout_callback(loc_id, *args)) location_scout.id = location_id category_layout.add_widget(location_scout) else: # Add invisible spacer to maintain 2-column grid structure category_layout.add_widget(Label(text="", size_hint=(None, None), height=0, width=0, opacity=0)) # if this is the category that Victory is in, display the Victory button # if ("category" in victory_location_data and location_category in victory_location_data["category"]) or \ # ("category" not in victory_location_data and location_category == "(No Category)"): if location_category in victory_categories: # Add the Victory location to be marked at any point, which is why locations length has 1 added to it above victory_text: str = "VICTORY! (seed finished)" if victory_location["name"] == "__Manual Game Complete__" else "GOAL: " + victory_location["name"] location_button = TreeViewButton(text=victory_text, size_hint=(1, None), height=dp(30), width=dp(400)) location_button.victory = True location_button.location_name = victory_location["name"] location_button.bind(on_release=self.victory_button_callback) category_layout.add_widget(location_button) tracker_panel_scrollable.add_widget(tracker_panel) locations_panel_scrollable.add_widget(locations_panel) self.tracker_and_locations_panel.add_widget(tracker_panel_scrollable) self.tracker_and_locations_panel.add_widget(locations_panel_scrollable) def check_for_requested_update(self): current_time = time.time() # wait 0.25 seconds before executing update, in case there are multiple update requests coming in if self.update_requested_time and current_time - self.update_requested_time >= 0.25: self.update_requested_time = None self.update_tracker_and_locations_table(self.update_requested_highlights) self.update_requested_highlights = False def request_update_tracker_and_locations_table(self, update_highlights=False): self.update_requested_time = time.time() self.update_requested_highlights = update_highlights or self.update_requested_highlights # if any of the requests wanted highlights, do highlight def update_tracker_and_locations_table(self, update_highlights=False): items_length = len(self.ctx.items_received) locations_length = len(self.ctx.missing_locations) if self.ctx.search_term: items_length = len([ i for i in self.ctx.items_received if self.ctx.search_term.lower() in self.ctx.item_names.lookup_in_game(i.item).lower() ]) locations_length = len([ l for l in self.ctx.missing_locations if self.ctx.search_term.lower() in self.ctx.location_names.lookup_in_game(l).lower() ]) for _, child in enumerate(self.tracker_and_locations_panel.children): # # Structure of items: # TrackerLayoutScrollable -> TreeView -> TreeViewLabel, TreeViewScrollView -> GridLayout -> Label # item tracker -> category -> category label, category scroll -> label col -> item # if type(child) is TrackerLayoutScrollable: treeview = child.children[0] # TreeView treeview_nodes = treeview.iterate_all_nodes() items_received_label = next(treeview_nodes) # always the first node items_received_label.text = "Items Received (%s)" % (items_length) # loop for each category in listed items and get the label + scrollview for x in range(0, len(self.item_categories)): category_label = next(treeview_nodes) # TreeViewLabel for category category_scrollview = next(treeview_nodes) # TreeViewScrollView for housing category's grid layout old_category_text = category_label.text if type(category_label) is TreeViewLabel and type(category_scrollview) is TreeViewScrollView: category_grid = category_scrollview.children[0] # GridLayout category_name = re.sub(r"\s\(\d+\)$", "", category_label.text) category_count = 0 category_unique_name_count = 0 existing_item_labels = [] bold_item_labels = [] # for items that were already listed, determine if the qty changed. if it did, add them to the list to be bolded for item in category_grid.children: if type(item) is ItemLabel: # Get the item name from the item Label, minus quantity, then do a lookup for count old_count = item.item_count item_name = item.item_name item_id = item.item_id if item_id: item_count = len(list(i for i in self.ctx.items_received if i.item == item_id)) else: item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == item_name)) # if the player is searching for text and the item name doesn't contain it, skip it if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower(): item.width = 0 item.height = 0 item.opacity = 0 else: item.width = dp(400) item.height = dp(30) item.opacity = 1 if item_count > 0: category_count += item_count category_unique_name_count += 1 # Update the label quantity if update_highlights and (old_count != item_count): bold_item_labels.append(item_name) item.item_count = item_count item.text="%s (%s)" % (item_name, item_count) existing_item_labels.append(item_name) # instead of reusing existing item listings, clear it all out and re-draw with the sorted list category_grid.clear_widgets() self.listed_items[category_name].clear() category_count = 0 category_unique_name_count = 0 # Label (for all item listings) item_sorting = SortingOrderItem[self.ctx.items_sorting] sorted_items_received = [i.item for i in self.ctx.items_received] def get_item_key(key: int) -> str: return self.ctx.get_sorting_key_by_id(key, is_item=True) if abs(item_sorting) == SortingOrderItem.alphabetical: sorted_items_received = sorted(sorted_items_received, key=get_item_key, reverse=item_sorting < 0) elif abs(item_sorting) == SortingOrderItem.natural: def alphanum_key(key: int) -> list[str|int]: return natural_sort_key(get_item_key(key)) sorted_items_received = sorted(sorted_items_received, key=alphanum_key, reverse=item_sorting < 0) elif abs(item_sorting) == SortingOrderItem.received: if item_sorting < 0: sorted_items_received.reverse() for network_item in sorted_items_received: item_name = self.ctx.item_names.lookup_in_game(network_item) item_data = self.ctx.get_item_by_name(item_name) # if the player is searching for text and the item name doesn't contain it, skip it if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower(): continue if "category" not in item_data or not item_data["category"]: item_data["category"] = ["(No Category)"] if category_name in item_data["category"] and network_item not in self.listed_items[category_name]: item_count = len(list(i for i in self.ctx.items_received if i.item == network_item)) item_text = ItemLabel(text="%s (%s)" % (item_name, item_count), size_hint=(None, None), height=dp(30), width=dp(400), bold=True) item_text.item_name = item_name item_text.item_count = item_count item_text.item_id = network_item # if the item was previously listed and was bold, or if it wasn't previously listed at all, make it bold item_text.bold = (update_highlights and (item_name in bold_item_labels or item_name not in existing_item_labels)) category_grid.add_widget(item_text) self.listed_items[category_name].append(network_item) category_count += item_count category_unique_name_count += 1 for event in sorted(self.ctx.tracker_reachable_events): if self.ctx.is_event_visible(event, category_name) and event not in self.listed_items[category_name]: item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == event)) item_text = ItemLabel(text="%s (%s)" % (event, item_count), size_hint=(None, None), height=dp(30), width=dp(400), bold=True) item_text.item_name = event item_text.item_count = item_count category_grid.add_widget(item_text) self.listed_items[category_name].append(event) category_count += item_count category_unique_name_count += 1 scrollview_height = 30 * category_unique_name_count if scrollview_height > 250: scrollview_height = 250 if scrollview_height < 10: scrollview_height = 50 category_name = re.sub(r"\s\(\d+\)$", "", category_label.text) category_label.text = "%s (%s)" % (category_name, category_count) if update_highlights: category_label.bold = True if old_category_text != category_label.text else False category_scrollview.size=(Window.width / 2, scrollview_height) # # Structure of locations: # LocationsLayoutScrollable -> TreeView -> TreeViewLabel, TreeViewScrollView -> GridLayout -> Button # location tracker -> category -> category label, category scroll -> label col -> location # if type(child) is LocationsLayoutScrollable: treeview = child.children[0] # TreeView treeview_nodes = treeview.iterate_all_nodes() locations_remaining_label = next(treeview_nodes) # always the first node locations_remaining_label.text = "Remaining Locations (%d)" % (locations_length) # loop for each category in listed items and get the label + scrollview for x in range(0, len(self.location_categories)): category_label = next(treeview_nodes) # TreeViewLabel for category category_scrollview = next(treeview_nodes) # TreeViewScrollView for housing category's grid layout if type(category_label) is TreeViewLabel and type(category_scrollview) is TreeViewScrollView: category_grid = category_scrollview.children[0] # GridLayout category_name = re.sub(r"\s\(\d+\/?(\d+)?\)$", "", category_label.text) category_count = 0 reachable_count = 0 reachable_glitch_count = 0 buttons_to_remove = [] # since victory is handled more briefly below, need to pull show/hide into functions here to reuse def show_button_during_search(btn: TreeViewButton): btn.width = dp(400) btn.height = dp(30) btn.opacity = 1 btn.disabled = False def hide_button_during_search(btn: TreeViewButton): btn.width = 0 btn.height = 0 btn.opacity = 0 btn.disabled = True # Label (for existing item listings) for location_button in category_grid.children: if type(location_button) is TreeViewButton: # Skip scout buttons if location_button.text == "Scout": continue # should only be true for the victory location button, which has different text if location_button.victory: # if the player is searching for text and the location name doesn't contain it, hide and disable it if self.ctx.search_term and not self.ctx.search_term.lower() in location_button.text.lower(): hide_button_during_search(location_button) else: show_button_during_search(location_button) category_count += 1 if location_button.victory and "__Victory__" in self.ctx.tracker_reachable_events: location_button.background_color = self.ctx.colors['location_in_logic'] reachable_count += 1 elif self.ctx.display_glitched_locations and location_button.victory and "__Victory__" in self.ctx.tracker_glitched_locations: location_button.background_color = self.ctx.colors['location_in_glitched_logic'] reachable_glitch_count += 1 continue if location_button.id and location_button.id not in self.ctx.missing_locations: logger.info("location button being removed: " + location_button.text) buttons_to_remove.append(location_button) continue was_reachable = False was_reachable_glitched = False if location_button.location_name in self.ctx.tracker_reachable_locations: location_button.background_color = self.ctx.colors['location_in_logic'] was_reachable = True elif self.ctx.display_glitched_locations and location_button.text in self.ctx.tracker_glitched_locations: location_button.background_color = self.ctx.colors['location_in_glitched_logic'] was_reachable_glitched = True else: location_button.background_color = self.ctx.colors['location_default'] # if the player is searching for text and the location name doesn't contain it, hide and disable it if self.ctx.search_term and not self.ctx.search_term.lower() in location_button.text.lower(): hide_button_during_search(location_button) else: show_button_during_search(location_button) if was_reachable: reachable_count += 1 elif was_reachable_glitched: reachable_glitch_count += 1 category_count += 1 for location_button in buttons_to_remove: parent = location_button.parent button_index = parent.children.index(location_button) # Grid cols=2: pairs are at adjacent indices (even with odd, odd with even) pair_index = button_index - 1 if button_index % 2 == 1 else button_index + 1 if 0 <= pair_index < len(parent.children): parent.remove_widget(parent.children[pair_index]) parent.remove_widget(location_button) scrollview_height = 30 * category_count if scrollview_height > 250: scrollview_height = 250 if scrollview_height < 10: scrollview_height = 50 count_text: str = str(category_count) if tracker_loaded: if self.ctx.display_glitched_locations and reachable_glitch_count: count_text = f"{reachable_count}/{category_count} ({reachable_glitch_count})" else: count_text = f"{reachable_count}/{category_count}" category_name = re.sub(r"\s\(\d+\/?([\d\s]+)?(\(\d+\))?\)$", "", category_label.text) category_label.text = "%s (%s)" % (category_name, count_text) if reachable_count > 0: # treeviewlabels don't have background color. because #justkivythings. category_label.even_color = self.ctx.colors['category_in_logic'] category_label.odd_color = self.ctx.colors['category_in_logic'] category_label.odd_color[3] = 0.9 elif self.ctx.display_glitched_locations and reachable_glitch_count > 0: category_label.even_color = self.ctx.colors['category_in_glitched_logic'] category_label.odd_color = self.ctx.colors['category_in_glitched_logic'] category_label.odd_color[3] = 0.9 else: category_label.even_color = self.ctx.colors['category_even_default'] category_label.odd_color = self.ctx.colors['category_odd_default'] category_scrollview.size=(Window.width / 2, scrollview_height) def location_button_callback(self, location_id: int, button: TreeViewButton): if button.location_name not in self.ctx.location_names_to_id: raise Exception("Locations were not loaded correctly. Please reconnect your client.") # if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath if self.are_top_controls_at_mouse_pos(): # if there's an obj in the top controls/tab at the current mouse position, click it instead if hovered_obj := self.get_top_obj_at_mouse_pos(): if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc. hovered_obj.trigger_action(duration=0) elif hasattr(hovered_obj, 'focus'): # text inputs hovered_obj.focus = True return if location_id: if tracker_loaded and self.ctx.block_unreachable_location_press: if button.location_name in self.ctx.tracker_reachable_locations: pass elif self.ctx.display_glitched_locations and self.ctx.allow_glitched_location_press \ and button.location_name in self.ctx.tracker_glitched_locations: pass else: logger.debug(f"button for location '{button.location_name}' was pressed while unreachable") return self.ctx.locations_checked.append(location_id) self.ctx.syncing = True # Remove both the location button and its adjacent scout button/spacer from 2-column grid parent = button.parent button_index = parent.children.index(button) # Grid cols=2: pairs are at adjacent indices (even with odd, odd with even) pair_index = button_index - 1 if button_index % 2 == 1 else button_index + 1 if 0 <= pair_index < len(parent.children): parent.remove_widget(parent.children[pair_index]) parent.remove_widget(button) # message = [{"cmd": 'LocationChecks', "locations": [location_id]}] # self.ctx.send_msgs(message) def location_scout_callback(self, location_id: int, button: TreeViewButton) -> None: # if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath if self.are_top_controls_at_mouse_pos(): # if there's an obj in the top controls/tab at the current mouse position, click it instead if hovered_obj := self.get_top_obj_at_mouse_pos(): if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc. hovered_obj.trigger_action(duration=0) elif hasattr(hovered_obj, 'focus'): # text inputs hovered_obj.focus = True return if location_id: self.ctx.locations_scouted.append(location_id) self.ctx.syncing = True def victory_button_callback(self, button: TreeViewButton): # if the mouse is currently hovering over any of the controls/tabs at the top of the client, ignore clicks for location buttons underneath if self.are_top_controls_at_mouse_pos(): # if there's an obj in the top controls/tab at the current mouse position, click it instead if hovered_obj := self.get_top_obj_at_mouse_pos(): if hasattr(hovered_obj, 'trigger_action'): # buttons, tabs, etc. hovered_obj.trigger_action(duration=0) elif hasattr(hovered_obj, 'focus'): # text inputs hovered_obj.focus = True return if tracker_loaded and self.ctx.block_unreachable_location_press and "__Victory__" not in self.ctx.tracker_reachable_events: logger.debug(f"button for location '{button.text}' was pressed while unreachable") else: self.ctx.goaled = True self.ctx.syncing = True return ManualManager async def game_watcher_manual(ctx: ManualContext): while not ctx.exit_event.is_set(): if ctx.ui: ctx.ui.check_for_requested_update() if ctx.syncing == True: sync_msg = [] if ctx.locations_checked: await ctx.check_locations(ctx.locations_checked) if ctx.locations_scouted: sync_msg.append({"cmd": "LocationScouts", "locations": list(ctx.locations_scouted), "create_as_hint": 2}) if sync_msg: await ctx.send_msgs(sync_msg) ctx.syncing = False if ctx.set_deathlink: ctx.set_deathlink = False await ctx.update_death_link(True) if ctx.deathlink_out: ctx.deathlink_out = False await ctx.send_death() victory = ctx.goaled ctx.locations_checked = [] ctx.locations_scouted = [] if not ctx.finished_game and victory: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) ctx.finished_game = True await asyncio.sleep(0.1) def read_apmanual_file(apmanual_file) -> dict[str, Any]: import zipfile from base64 import b64decode from .container import APManualFile if zipfile.is_zipfile(apmanual_file): try: container = APManualFile(apmanual_file) container.read() return container.get_manifest() except Exception as e: logger.exception("Error reading APManual file:", e) with open(apmanual_file, "r") as f: return json.loads(b64decode(f.read())) async def main(args): config_file = {} if args.apmanual_file and os.path.exists(args.apmanual_file): config_file = read_apmanual_file(args.apmanual_file) if config_file.get("server") and not args.connect: args.connect = config_file["server"] ctx = ManualContext(args.connect, args.password, config_file.get("game"), config_file.get("player_name")) ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.item_table = config_file.get("items", {}) ctx.location_table = config_file.get("locations", {}) ctx.region_table = config_file.get("regions", {}) ctx.category_table = config_file.get("categories", {}) if tracker_loaded: ctx.run_generator() if gui_enabled: ctx.run_gui() ctx.run_cli() progression_watcher = asyncio.create_task( game_watcher_manual(ctx), name="ManualProgressionWatcher") await ctx.exit_event.wait() ctx.server_address = None await progression_watcher await ctx.shutdown() def launch(*launch_args) -> None: import colorama parser = get_base_parser(description="Manual Client, for operating a Manual game in Archipelago.") parser.add_argument('apmanual_file', default="", type=str, nargs="?", help='Path to an APMANUAL file') args, rest = parser.parse_known_args(launch_args) colorama.init() asyncio.run(main(args)) colorama.deinit() if not os.path.exists(icon_paths["manual"]): # Download the icon for next time icon_url = "https://manualforarchipelago.github.io/ManualBuilder/images/ap-manual-discord-logo-square-96x96.png" with open(icon_paths["manual"], 'wb') as f: f.write(requests.get(icon_url).content) if __name__ == '__main__': launch()