From 2dd904e7586b6ac974c86f1cf778d3b257e9c91a Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Fri, 10 Nov 2023 22:06:54 -0800 Subject: [PATCH] Allow worlds to provide item and location descriptions (#2409) These are displayed in the weighted options page as hoverable tooltips. --- WebHostLib/options.py | 24 ++++---- WebHostLib/static/assets/weighted-options.js | 40 +++++++++++-- docs/world api.md | 63 ++++++++++++++++++++ test/bases.py | 21 +++++++ worlds/AutoWorld.py | 35 +++++++++++ worlds/dark_souls_3/Items.py | 8 +++ worlds/dark_souls_3/__init__.py | 3 +- 7 files changed, 177 insertions(+), 17 deletions(-) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 1a2aab6d..3c0f47f3 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -136,16 +136,20 @@ def create(): option["defaultValue"] = "random" weighted_options["baseOptions"]["game"][game_name] = 0 - weighted_options["games"][game_name] = {} - weighted_options["games"][game_name]["gameSettings"] = game_options - weighted_options["games"][game_name]["gameItems"] = tuple(world.item_names) - weighted_options["games"][game_name]["gameItemGroups"] = [ - group for group in world.item_name_groups.keys() if group != "Everything" - ] - weighted_options["games"][game_name]["gameLocations"] = tuple(world.location_names) - weighted_options["games"][game_name]["gameLocationGroups"] = [ - group for group in world.location_name_groups.keys() if group != "Everywhere" - ] + weighted_options["games"][game_name] = { + "gameSettings": game_options, + "gameItems": tuple(world.item_names), + "gameItemGroups": [ + group for group in world.item_name_groups.keys() if group != "Everything" + ], + "gameItemDescriptions": world.item_descriptions, + "gameLocations": tuple(world.location_names), + "gameLocationGroups": [ + group for group in world.location_name_groups.keys() if group != "Everywhere" + ], + "gameLocationDescriptions": world.location_descriptions, + } with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f: json.dump(weighted_options, f, indent=2, separators=(',', ': ')) + diff --git a/WebHostLib/static/assets/weighted-options.js b/WebHostLib/static/assets/weighted-options.js index 3811bd42..34dfbae4 100644 --- a/WebHostLib/static/assets/weighted-options.js +++ b/WebHostLib/static/assets/weighted-options.js @@ -1024,12 +1024,18 @@ class GameSettings { // Builds a div for a setting whose value is a list of locations. #buildLocationsDiv(setting) { - return this.#buildListDiv(setting, this.data.gameLocations, this.data.gameLocationGroups); + return this.#buildListDiv(setting, this.data.gameLocations, { + groups: this.data.gameLocationGroups, + descriptions: this.data.gameLocationDescriptions, + }); } // Builds a div for a setting whose value is a list of items. #buildItemsDiv(setting) { - return this.#buildListDiv(setting, this.data.gameItems, this.data.gameItemGroups); + return this.#buildListDiv(setting, this.data.gameItems, { + groups: this.data.gameItemGroups, + descriptions: this.data.gameItemDescriptions + }); } // Builds a div for a setting named `setting` with a list value that can @@ -1038,12 +1044,15 @@ class GameSettings { // The `groups` option can be a list of additional options for this list // (usually `item_name_groups` or `location_name_groups`) that are displayed // in a special section at the top of the list. - #buildListDiv(setting, items, groups = []) { + // + // The `descriptions` option can be a map from item names or group names to + // descriptions for the user's benefit. + #buildListDiv(setting, items, {groups = [], descriptions = {}} = {}) { const div = document.createElement('div'); div.classList.add('simple-list'); groups.forEach((group) => { - const row = this.#addListRow(setting, group); + const row = this.#addListRow(setting, group, descriptions[group]); div.appendChild(row); }); @@ -1052,7 +1061,7 @@ class GameSettings { } items.forEach((item) => { - const row = this.#addListRow(setting, item); + const row = this.#addListRow(setting, item, descriptions[item]); div.appendChild(row); }); @@ -1060,7 +1069,9 @@ class GameSettings { } // Builds and returns a row for a list of checkboxes. - #addListRow(setting, item) { + // + // If `help` is passed, it's displayed as a help tooltip for this list item. + #addListRow(setting, item, help = undefined) { const row = document.createElement('div'); row.classList.add('list-row'); @@ -1081,6 +1092,23 @@ class GameSettings { const name = document.createElement('span'); name.innerText = item; + + if (help) { + const helpSpan = document.createElement('span'); + helpSpan.classList.add('interactive'); + helpSpan.setAttribute('data-tooltip', help); + helpSpan.innerText = '(?)'; + name.innerText += ' '; + name.appendChild(helpSpan); + + // Put the first 7 tooltips below their rows. CSS tooltips in scrolling + // containers can't be visible outside those containers, so this helps + // ensure they won't be pushed out the top. + if (helpSpan.parentNode.childNodes.length < 7) { + helpSpan.classList.add('tooltip-bottom'); + } + } + label.appendChild(name); row.appendChild(label); diff --git a/docs/world api.md b/docs/world api.md index b128e2b1..9b7573dc 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -121,6 +121,38 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being required, and will prevent progression and useful items from being placed at excluded locations. +#### Documenting Locations + +Worlds can optionally provide a `location_descriptions` map which contains +human-friendly descriptions of locations or location groups. These descriptions +will show up in location-selection options in the Weighted Options page. Extra +indentation and single newlines will be collapsed into spaces. + +```python +# Locations.py + +location_descriptions = { + "Red Potion #6": "In a secret destructible block under the second stairway", + "L2 Spaceship": """ + The group of all items in the spaceship in Level 2. + + This doesn't include the item on the spaceship door, since it can be + accessed without the Spaeship Key. + """ +} +``` + +```python +# __init__.py + +from worlds.AutoWorld import World +from .Locations import location_descriptions + + +class MyGameWorld(World): + location_descriptions = location_descriptions +``` + ### Items Items are all things that can "drop" for your game. This may be RPG items like @@ -147,6 +179,37 @@ Other classifications include * `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that will not be moved around by progression balancing; used, e.g., for currency or tokens +#### Documenting Items + +Worlds can optionally provide an `item_descriptions` map which contains +human-friendly descriptions of items or item groups. These descriptions will +show up in item-selection options in the Weighted Options page. Extra +indentation and single newlines will be collapsed into spaces. + +```python +# Items.py + +item_descriptions = { + "Red Potion": "A standard health potion", + "Spaceship Key": """ + The key to the spaceship in Level 2. + + This is necessary to get to the Star Realm. + """ +} +``` + +```python +# __init__.py + +from worlds.AutoWorld import World +from .Items import item_descriptions + + +class MyGameWorld(World): + item_descriptions = item_descriptions +``` + ### Events Events will mark some progress. You define an event location, an diff --git a/test/bases.py b/test/bases.py index 2054c2d1..3d704579 100644 --- a/test/bases.py +++ b/test/bases.py @@ -333,3 +333,24 @@ class WorldTestBase(unittest.TestCase): placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code] self.assertLessEqual(len(self.multiworld.itempool), len(placed_items), "Unplaced Items remaining in itempool") + + def test_descriptions_have_valid_names(self): + """Ensure all item and location descriptions match a name of the corresponding type""" + if not (self.run_default_tests and self.constructed): + return + with self.subTest("Game", game=self.game): + with self.subTest("Items"): + world = self.multiworld.worlds[1] + valid_names = world.item_names.union(world.item_name_groups) + for name in world.item_descriptions.keys(): + with self.subTest("Name should be valid", name=name): + self.assertIn(name, valid_names, + """All item descriptions must match defined item names""") + + with self.subTest("Locations"): + world = self.multiworld.worlds[1] + valid_names = world.location_names.union(world.location_name_groups) + for name in world.location_descriptions.keys(): + with self.subTest("Name should be valid", name=name): + self.assertIn(name, valid_names, + """All item descriptions must match defined item names""") diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index d05797cf..5b4dec83 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import logging import pathlib +import re import sys import time from dataclasses import make_dataclass @@ -51,11 +52,17 @@ class AutoWorldRegister(type): dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set in dct.get("item_name_groups", {}).items()} dct["item_name_groups"]["Everything"] = dct["item_names"] + dct["item_descriptions"] = {name: _normalize_description(description) for name, description + in dct.get("item_descriptions", {}).items()} + dct["item_descriptions"]["Everything"] = "All items in the entire game." dct["location_names"] = frozenset(dct["location_name_to_id"]) dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set in dct.get("location_name_groups", {}).items()} dct["location_name_groups"]["Everywhere"] = dct["location_names"] dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {}))) + dct["location_descriptions"] = {name: _normalize_description(description) for name, description + in dct.get("location_descriptions", {}).items()} + dct["location_descriptions"]["Everywhere"] = "All locations in the entire game." # move away from get_required_client_version function if "game" in dct: @@ -205,9 +212,23 @@ class World(metaclass=AutoWorldRegister): item_name_groups: ClassVar[Dict[str, Set[str]]] = {} """maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}""" + item_descriptions: ClassVar[Dict[str, str]] = {} + """An optional map from item names (or item group names) to brief descriptions for users. + + Individual newlines and indentation will be collapsed into spaces before these descriptions are + displayed. This may cover only a subset of items. + """ + location_name_groups: ClassVar[Dict[str, Set[str]]] = {} """maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}""" + location_descriptions: ClassVar[Dict[str, str]] = {} + """An optional map from location names (or location group names) to brief descriptions for users. + + Individual newlines and indentation will be collapsed into spaces before these descriptions are + displayed. This may cover only a subset of locations. + """ + data_version: ClassVar[int] = 0 """ Increment this every time something in your world's names/id mappings changes. @@ -462,3 +483,17 @@ def data_package_checksum(data: "GamesPackage") -> str: assert sorted(data) == list(data), "Data not ordered" from NetUtils import encode return hashlib.sha1(encode(data).encode()).hexdigest() + + +def _normalize_description(description): + """Normalizes a description in item_descriptions or location_descriptions. + + This allows authors to write descritions with nice indentation and line lengths in their world + definitions without having it affect the rendered format. + """ + # First, collapse the whitespace around newlines and the ends of the description. + description = re.sub(r' *\n *', '\n', description.strip()) + # Next, condense individual newlines into spaces. + description = re.sub(r'(?