diff --git a/schemas/Manual.items.schema.json b/schemas/Manual.items.schema.json index 0298c05f..7fa4745c 100644 --- a/schemas/Manual.items.schema.json +++ b/schemas/Manual.items.schema.json @@ -116,6 +116,10 @@ "description": "(Optional) A string to sort the items by. If not provided, items will always be sorted by name.", "type": "string" }, + "description": { + "description": "(Optional) A short description of the item to be displayed in the client", + "type": "string" + }, "_comment": {"$ref": "#/definitions/comment"} }, "required": ["name"] diff --git a/schemas/Manual.locations.schema.json b/schemas/Manual.locations.schema.json index 829539df..52a7f6e6 100644 --- a/schemas/Manual.locations.schema.json +++ b/schemas/Manual.locations.schema.json @@ -104,6 +104,10 @@ "description": "(Optional) A string to sort the locations by. If not provided, locations will always be sorted by name.", "type": "string" }, + "description": { + "description": "(Optional) A short description of the location to be displayed in the client in the Manual and Tracker tabs", + "type": "string" + }, "_comment": {"$ref": "#/definitions/comment"} }, "required": ["name"] diff --git a/src/Items.py b/src/Items.py index 68c76776..ad5ce21a 100644 --- a/src/Items.py +++ b/src/Items.py @@ -9,6 +9,7 @@ item_id_to_name: dict[int, str] = {} item_name_to_item: dict[str, dict] = {} +item_name_to_description: dict[str, str] = {} item_name_groups: dict[str, str] = {} advancement_item_names: set[str] = set() lastItemId = -1 @@ -38,7 +39,7 @@ count += 1 for item in item_table: - item_name = item.get("name", f"Unnamed Item {item['id']}") + item_name: str = item.get("name", f"Unnamed Item {item['id']}") item_id_to_name[item["id"]] = item_name item_name_to_item[item_name] = item @@ -60,6 +61,9 @@ item_name_groups[group_name] = [] item_name_groups[group_name].append(item_name) + if item.get("description"): + item_name_to_description[item_name] = item["description"] + item_id_to_name[None] = "__Victory__" item_name_to_id = {name: id for id, name in item_id_to_name.items()} diff --git a/src/Locations.py b/src/Locations.py index d2ee209d..75692e09 100644 --- a/src/Locations.py +++ b/src/Locations.py @@ -46,11 +46,12 @@ location_id_to_name: dict[int, str] = {} location_name_to_location: dict[str, dict[str, Any]] = {} +location_name_to_description: dict[str, str] = {} location_name_groups: dict[str, list[str]] = {} event_name_to_event: dict[str, dict[str, Any]] = {} for loc in location_table: - loc_name = loc.get("name", f"Unnamed Location {loc['id']}") + loc_name: str = loc.get("name", f"Unnamed Location {loc['id']}") location_id_to_name[loc["id"]] = loc_name location_name_to_location[loc_name] = loc @@ -59,6 +60,8 @@ location_name_groups[c] = [] location_name_groups[c].append(loc_name) + if loc.get("description"): + location_name_to_description[loc_name] = loc["description"] # location_id_to_name[None] = "__Manual Game Complete__" location_name_to_id = {name: id for id, name in location_id_to_name.items()} diff --git a/src/ManualClient.py b/src/ManualClient.py index 3ac9d9bd..72c39da0 100644 --- a/src/ManualClient.py +++ b/src/ManualClient.py @@ -65,6 +65,17 @@ class SortingOrderItem(IntEnum): SortingOrderItem.natural.__doc__ = "Sort like custom 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() @@ -76,6 +87,14 @@ def strip_articles(title: str) -> str: 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): def _cmd_resync(self) -> bool: """Manually trigger a resync.""" @@ -121,18 +140,22 @@ class ManualContext(SuperContext): region_table = {} category_table = {} - tracker_reachable_locations = [] - tracker_reachable_events = [] + tracker_reachable_locations: list[str] = [] + tracker_reachable_events: list[str] = [] set_deathlink = False last_death_link = 0 deathlink_out = False - visible_events = {} + visible_events: dict[str, dict[str, Any]] = {} + location_name_to_description: dict[str, str] = {} + item_name_to_description: dict[str, str] = {} search_term = "" items_sorting = SortingOrderItem.default.name + item_categories_sorting = SortingOrderCategories.default.name locations_sorting = SortingOrderLoc.default.name + location_categories_sorting = SortingOrderCategories.default.name block_unreachable_location_press = True colors = { @@ -158,7 +181,7 @@ def __init__(self, server_address, password, game, player_name) -> None: self.send_index: int = 0 self.syncing = False - self.game = game + self.game: str = game self.username = player_name async def server_auth(self, password_requested: bool = False): @@ -198,6 +221,20 @@ def suggested_game(self) -> str: 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_description_by_name(self, name: str) -> str|None: + # First we try to get it from slotdata for dynamic descriptions + description = self.location_name_to_description.get(name, None) + # Secondly we try to get it from the world itself for a more static description + if description is None: + description = AutoWorldRegister.world_types[self.game].web.location_descriptions.get(name, None) + return description + + def get_item_description_by_name(self, name: str) -> str|None: + description = self.item_name_to_description.get(name, None) + if description is None: + description = AutoWorldRegister.world_types[self.game].web.item_descriptions.get(name, None) + return description + def get_location_by_name(self, name) -> dict[str, Any]: location = self.location_table.get(name) if not location: @@ -260,6 +297,8 @@ def on_package(self, cmd: str, args: dict): self.set_deathlink = True self.last_death_link = 0 self.visible_events = args['slot_data'].get('visible_events', {}) + self.location_name_to_description = args['slot_data'].get('location_name_to_description', {}) + self.item_name_to_description = args['slot_data'].get('item_name_to_description', {}) logger.info(f"Slot data: {args['slot_data']}") self.ui.build_tracker_and_locations_table() @@ -363,7 +402,14 @@ class LocationsLayoutScrollable(ScrollView): class TreeViewButton(Button, TreeViewNode): victory: bool = False - id: int = None + id: int|None = None + location_name: str = "" + + class ItemLabel(Label): + item_id: int|None = None + item_count: int = 1 + item_name: str = "" + item_description: str = "" class TreeViewScrollView(ScrollView, TreeViewNode): pass @@ -416,6 +462,8 @@ def build(self) -> Layout: self.ctx.items_sorting = self.config.get('manual', 'items_sorting_order') self.ctx.locations_sorting = self.config.get('manual', 'locations_sorting_order') + self.ctx.item_categories_sorting = self.config.get('manual', 'item_categories_sorting_order') + self.ctx.location_categories_sorting = self.config.get('manual', 'location_categories_sorting_order') self.ctx.block_unreachable_location_press = True if self.config.get('universal-tracker', 'block_unreachable_location_press') == "Yes" else False self.manual_game_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30)) @@ -450,7 +498,9 @@ def build_config(self, config: ConfigParser): super().build_config(config) config.setdefaults("manual", { "items_sorting_order": SortingOrderItem.default.name, - "locations_sorting_order": SortingOrderLoc.default.name + "locations_sorting_order": SortingOrderLoc.default.name, + "item_categories_sorting_order": SortingOrderCategories.default.name, + "location_categories_sorting_order": SortingOrderCategories.default.name }) config.setdefaults("universal-tracker", { "block_unreachable_location_press": "Yes" @@ -480,6 +530,22 @@ def build_settings(self, settings: Settings): "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": "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": "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." + }, ] if tracker_loaded: json_data.extend([ @@ -510,6 +576,16 @@ def on_config_change(self, config, section, key, value): self.ctx.locations_sorting = value 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 == "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 section == "universal-tracker": if key == "block_unreachable_location_press": self.ctx.block_unreachable_location_press = True if value == "Yes" else False @@ -740,15 +816,8 @@ def build_tracker_and_locations_table(self): reverse=loc_sorting < 0) elif abs(loc_sorting) == SortingOrderLoc.natural: - # Modified from https://stackoverflow.com/a/11150413 - def convert(text): - return int(text) if text.isdigit() else text.lower() - - def alphanum_key(i): - name = strip_articles(self.ctx.get_location_by_id(i).get("name", "")) - - return [convert(c) for c in re.split('([0-9]+)', self.ctx.get_location_by_id(i).get("sort-key", name))] - + def alphanum_key(key: int) -> list[str|int]: + return natural_sort_key(self.ctx.get_location_by_id(key).get("sort-key",self.ctx.get_location_by_id(key).get("name", ""))) for category in self.listed_locations: self.listed_locations[category].sort(key=alphanum_key, reverse=loc_sorting < 0) @@ -758,8 +827,18 @@ def alphanum_key(i): 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 category_sort_key(key: str): + result = natural_sort_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=category_sort_key, reverse=item_cat_sorting < 0)} + else: + self.listed_items = {key: self.listed_items[key] for key in sorted(self.listed_items.keys(), reverse=item_cat_sorting < 0)} + # Since items_received is not available on connect, don't bother building item labels here - for item_category in sorted(self.listed_items.keys()): + 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]))) ) @@ -778,7 +857,14 @@ def alphanum_key(i): 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)) - for location_category in sorted(self.listed_locations.keys()): + # Sorting location categories + loc_cat_sorting = SortingOrderCategories[self.ctx.location_categories_sorting] + if abs(loc_cat_sorting) == SortingOrderCategories.natural: + self.listed_locations = {key: self.listed_locations[key] for key in sorted(self.listed_locations.keys(), key=category_sort_key, reverse=loc_cat_sorting < 0)} + else: + self.listed_locations = {key: self.listed_locations[key] for key in sorted(self.listed_locations.keys(), 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 \ @@ -796,10 +882,13 @@ def alphanum_key(i): for location_id in self.listed_locations[location_category]: has_hint = location_id in hinted_locations or location_id in scoutable_locations - - location_button = TreeViewButton(text=self.ctx.location_names.lookup_in_game(location_id), size_hint=(.75 if has_hint else 1, None), height=30) + location_name = self.ctx.location_names.lookup_in_game(location_id) + extra = f' ({description})' if (description := self.ctx.get_location_description_by_name(location_name)) is not None else '' + text = f"{location_name}{extra}" + 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 = location_name category_layout.add_widget(location_button) if location_id in hinted_locations: @@ -818,9 +907,12 @@ def alphanum_key(i): # ("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 = "VICTORY! (seed finished)" if victory_location["name"] == "__Manual Game Complete__" else "GOAL: " + victory_location["name"] - location_button = TreeViewButton(text=victory_text, size_hint=(None, None), height=dp(30), width=dp(400)) + location_name = victory_location["name"] + extra = f' ({description})' if (description := self.ctx.get_location_description_by_name(location_name)) is not None else '' + victory_text: str = "VICTORY! (seed finished)" if location_name == "__Manual Game Complete__" else "GOAL: " + location_name + extra + location_button = TreeViewButton(text=victory_text, size_hint=(1, None), height=dp(30), width=dp(400)) location_button.victory = True + location_button.location_name = location_name location_button.bind(on_release=self.victory_button_callback) category_layout.add_widget(location_button) @@ -889,11 +981,11 @@ def update_tracker_and_locations_table(self, update_highlights=False): # 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 Label: + if type(item) is ItemLabel: # Get the item name from the item Label, minus quantity, then do a lookup for count - old_item_text = item.text - item_name = re.sub(r"\s\(\d+\)$", "", item.text) - item_id = self.ctx.item_names_to_id.get(item_name, False) + 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: @@ -914,10 +1006,11 @@ def update_tracker_and_locations_table(self, update_highlights=False): category_unique_name_count += 1 # Update the label quantity - item.text="%s (%s)" % (item_name, item_count) - - if update_highlights and (old_item_text != item.text): + if update_highlights and (old_count != item_count): bold_item_labels.append(item_name) + item.item_count = item_count + + item.text="%s %s(%s)" % (item_name, item.item_description, item_count) existing_item_labels.append(item_name) @@ -941,14 +1034,9 @@ def update_tracker_and_locations_table(self, update_highlights=False): reverse=item_sorting < 0) elif abs(item_sorting) == SortingOrderItem.natural: - def convert(text): - return int(text) if text.isdigit() else text.lower() - def alphanum_key(i): - name = self.ctx.get_item_by_id(i).get("name", "") - name = strip_articles(name) - - return [convert(c) for c in re.split('([0-9]+)',self.ctx.get_item_by_id(i).get("sort-key", name)) - ] + def alphanum_key(key: int) -> list[str|int]: + return natural_sort_key(self.ctx.get_item_by_id(key).get("sort-key", self.ctx.get_item_by_id(key).get("name", ""))) + sorted_items_received = sorted(sorted_items_received, key=alphanum_key, reverse=item_sorting < 0) elif abs(item_sorting) == SortingOrderItem.received: @@ -968,8 +1056,13 @@ def alphanum_key(i): 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 = Label(text="%s (%s)" % (item_name, item_count), + description = f'({description}) ' if (description := self.ctx.get_item_description_by_name(item_name)) is not None else '' + item_text = ItemLabel(text="%s %s(%s)" % (item_name, description, 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 + item_text.item_description = description # 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)) @@ -983,8 +1076,10 @@ def alphanum_key(i): 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 = Label(text="%s (%s)" % (event, item_count), + 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 @@ -1052,7 +1147,7 @@ def hide_button_during_search(btn: TreeViewButton): if location_button.text == "Scout": continue # should only be true for the victory location button, which has different text - if location_button.text not in (self.ctx.location_table or AutoWorldRegister.world_types[self.ctx.game].location_name_to_location): + 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) @@ -1067,15 +1162,13 @@ def hide_button_during_search(btn: TreeViewButton): continue if location_button.id and location_button.id not in self.ctx.missing_locations: - import logging - - logging.info("location button being removed: " + location_button.text) + logger.info("location button being removed: " + location_button.text) buttons_to_remove.append(location_button) continue was_reachable = False - if location_button.text in self.ctx.tracker_reachable_locations: + if location_button.location_name in self.ctx.tracker_reachable_locations: location_button.background_color = self.ctx.colors['location_in_logic'] was_reachable = True else: @@ -1128,7 +1221,7 @@ def hide_button_during_search(btn: TreeViewButton): category_scrollview.size=(Window.width / 2, scrollview_height) def location_button_callback(self, location_id: int, button: TreeViewButton): - if button.text not in self.ctx.location_names_to_id: + 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 @@ -1143,7 +1236,7 @@ def location_button_callback(self, location_id: int, button: TreeViewButton): return if location_id: - if tracker_loaded and self.ctx.block_unreachable_location_press and button.text not in self.ctx.tracker_reachable_locations: + if tracker_loaded and self.ctx.block_unreachable_location_press and button.location_name not in self.ctx.tracker_reachable_locations: logger.debug(f"button for location '{button.text}' was pressed while unreachable") else: self.ctx.locations_checked.append(location_id) @@ -1176,7 +1269,7 @@ def location_scout_callback(self, location_id: int, button: TreeViewButton) -> N self.ctx.locations_scouted.append(location_id) self.ctx.syncing = True - def victory_button_callback(self, button): + 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 @@ -1188,8 +1281,11 @@ def victory_button_callback(self, button): return - self.ctx.items_received.append("__Victory__") - self.ctx.syncing = True + 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.items_received.append("__Victory__") + self.ctx.syncing = True return ManualManager diff --git a/src/Meta.py b/src/Meta.py index 0056aff8..a9513a09 100644 --- a/src/Meta.py +++ b/src/Meta.py @@ -28,8 +28,14 @@ def set_world_description(base_doc: str) -> str: return base_doc -def set_world_webworld(web: WebWorld) -> WebWorld: +def set_world_webworld(web: ManualWeb) -> ManualWeb: from .Options import make_options_group + from .Locations import location_name_to_description + from .Items import item_name_to_description + + web.location_descriptions = location_name_to_description + web.item_descriptions = item_name_to_description + if meta_table.get("docs", {}).get("web", {}): Web_Config = meta_table["docs"]["web"] diff --git a/src/__init__.py b/src/__init__.py index cdfb1478..be101505 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -10,7 +10,7 @@ from .Data import item_table, location_table, event_table, region_table, category_table from .Game import game_name, filler_item_name, starting_items from .Meta import world_description, world_webworld -from .Locations import location_id_to_name, location_name_to_id, location_name_to_location, location_name_groups, victory_names, event_name_to_event +from .Locations import location_id_to_name, location_name_to_id, location_name_to_location, location_name_groups, victory_names, event_name_to_event, location_name_to_description from .Items import item_id_to_name, item_name_to_id, item_name_to_item, item_name_groups from .DataValidation import runGenerationDataValidation, runPreFillDataValidation @@ -71,6 +71,7 @@ class ManualWorld(World): # UT (the universal-est of trackers) can now generate without a YAML ut_can_gen_without_yaml = True + location_id_to_alias: dict[int, str] = {location_name_to_id[name]: desc for name, desc in location_name_to_description.items()} origin_region_name = "Manual" @@ -417,6 +418,7 @@ def fill_slot_data(self): # slot_data["DeathLink"] = bool(self.multiworld.death_link[self.player].value) common_options = set(PerGameCommonOptions.type_hints.keys()) + common_options |= set(["generate_region_diagram", "start_inventory_from_pool"]) for option_key, _ in self.options_dataclass.type_hints.items(): if option_key in common_options: continue @@ -570,7 +572,7 @@ def __init__(self, display_name: str, script_name: Optional[str] = None, func: O self.version = version def add_client_to_launcher() -> None: - version = 2026_04_04 # YYYYMMDD + version = 2026_04_07 # YYYYMMDD found = False if "manual" not in icon_paths: