Allow worlds to provide item and location descriptions (#2409)

These are displayed in the weighted options page as hoverable tooltips.
This commit is contained in:
Natalie Weizenbaum 2023-11-10 22:06:54 -08:00 committed by GitHub
parent 64159a6d0f
commit 2dd904e758
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 177 additions and 17 deletions

View File

@ -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=(',', ': '))

View File

@ -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);

View File

@ -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

View File

@ -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""")

View File

@ -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'(?<!\n)\n(?!\n)', ' ', description)
return description

View File

@ -1271,6 +1271,14 @@ _cut_content_items = [DS3ItemData(row[0], row[1], False, row[2]) for row in [
("Dorris Swarm", 0x40393870, DS3ItemCategory.SKIP),
]]
item_descriptions = {
"Cinders": """
All four Cinders of a Lord.
Once you have these four, you can fight Soul of Cinder and win the game.
""",
}
_all_items = _vanilla_items + _dlc_items
item_dictionary = {item_data.name: item_data for item_data in _all_items}

View File

@ -7,7 +7,7 @@ from Options import Toggle
from worlds.AutoWorld import World, WebWorld
from worlds.generic.Rules import set_rule, add_rule, add_item_rule
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names, item_descriptions
from .Locations import DarkSouls3Location, DS3LocationCategory, location_tables, location_dictionary
from .Options import RandomizeWeaponLevelOption, PoolTypeOption, dark_souls_options
@ -60,6 +60,7 @@ class DarkSouls3World(World):
"Cinders of a Lord - Lothric Prince"
}
}
item_descriptions = item_descriptions
def __init__(self, multiworld: MultiWorld, player: int):