Repository navigation
feat(items): named placeholders in item descriptions - #817
Conversation
A description text can show values of the item: "{name}" is replaced
when the tooltip draws it. A name is a built-in value ("level", the
item's + level) or a level bonus table of Stats/ItemStats.json, at the
item's + level, so a new table is a new placeholder without code.
Placeholders are checked when the item data loads, in the save checks
and in Import from bmd, so a wrong one fails the shipped-data tests:
- a "{" without its "}", an empty "{}", a "}" without its "{", or a name
that is not letters and digits, in any language (item texts have no
braces of their own, so there is no escape);
- a translation whose placeholders are not the English text's;
- a name that is neither built in nor a table, with the nearest known
name when it is at most two letters away ("did you mean ...?").
A table of the stat file may not take a built-in value's name.
No shipped text uses a placeholder yet.
…item-description-placeholders # Conflicts: # src/source/Data/GameData/ItemData/ItemStatsJson.cpp # tests/data/CMakeLists.txt
…item-description-placeholders # Conflicts: # docs/item-data.md # src/source/Engine/Object/ZzzInventory.cpp
…item-description-placeholders
…item-description-placeholders # Conflicts: # docs/item-data.md # src/source/Engine/Object/ZzzInventory.cpp
…iption-placeholders # Conflicts: # docs/item-data.md
Mosch0512
left a comment
There was a problem hiding this comment.
Verdict: changes requested for two items in the editor; the rest is non-blocking. The placeholder mechanism itself is solid. Braces, translations, typo suggestions and the reserved level name are checked where the data loads, saves and imports, and the tests cover each error. Thanks for merging main yourself; the docs conflict is resolved the way I would have.
The two items come from the stat file's new role. Since this PR, descriptions name level bonus tables, but Item Formulas doesn't know that yet. #818 makes it real right away: it adds 11 tables (wingDamageIncrease1st … capeOfLordDamageAbsorb) that only descriptions use, and no stat profile.
Required
- Item Formulas counts a table's users only from the stat sections (
CountTableUsers, MuItemFormulasUI.cpp:74). A table that only descriptions use shows "(0)", is listed among the tables nothing uses, and its Remove button is enabled. Removing it goes into the database at once. Its tooltips then draw the raw{wingDamageIncrease2nd}, and Save Items is blocked by "unknown placeholder" errors that no tool can fix before step 6. D26: a definition that is still used can't be deleted, and the editor shows where it's used. That file isn't in this PR's diff, so there's no inline comment. - Renaming a table updates the stat sections that name it, but not the descriptions (
RenameTable, MuItemFormulasUI.cpp:92). After a rename, the same raw text and the same Save error follow. D26: renaming updates all references.
Non-blocking
- Built-ins and table names share one set of names. Worth deciding before step 4 writes table placeholders into the shipped data (inline, ItemStatsJson.cpp:237).
- Table names are looked up while drawing, every frame (inline, ItemDescriptionPlaceholders.cpp:107).
- No test checks that the tooltip fills in the values (inline, ZzzInventory.cpp:1640).
- Item Formulas' name dialog (
IsFreeName, MuItemFormulasUI.cpp:970) accepts a table namedlevel. Only Save Items' read-back check of the stat file refuses it. Refusing it in the dialog, with the "free name" note the other taken names get, catches it where it's typed. - The docs example uses a flat bonus as a percentage (inline, item-data.md:143).
- "Description placeholders" sits between the fields table and Tags, away from the Descriptions section it extends (inline, item-data.md:139).
- Two file-private helpers have external linkage (inline, ItemDataValidation.cpp:237).
- The field name
description[i].textis still built by hand at lines 94 and 444 of ItemDataValidation.cpp, next to the newDescriptionTextField. Calling it there keeps one spelling of the field name. - Nothing tests that Load and Save Items actually run the name check (inline, ItemDataHandler.cpp:123).
- Two separate passes check the same description texts (inline, ItemDataValidation.cpp:320).
What I checked: I read this step's diff against #816, and again after your merge of main (this step's code is unchanged). I also went through:
- the brace parsing at its edges, and how
GetBonusclamps the level; - the item data handler's three calls, the stat file reader, and Save Items' read-back check;
- Item Formulas' table users, rename and name dialog;
- #818's
ItemStats.json, for which tables only descriptions use.
I built this branch merged with main locally (Windows x64 Debug): test_item_database passes (165 test cases) and test_item_database_files passes (161). CI: Quality Gates pass; the Linux and macOS builds were still running.
| continue; | ||
| } | ||
| // Item descriptions name the tables as placeholders, next to the built-in ones. | ||
| const std::span<const std::string_view> builtIns = GetBuiltInPlaceholders(); |
There was a problem hiding this comment.
Non-blocking, but worth deciding before step 4. Built-in values and table names share one set of names, and a table named like a built-in is a load error. #815 plans about 40 more built-ins for step 5 (damageMin, defense, durability, requiredStrength, the resistances, …). Each one turns any stat file that already has a table of that name into one that no longer loads.
For example, a server owner adds a table named durability to their ItemStats.json. When step 5 makes durability a built-in, their client stops at start with "is a built-in placeholder of the item descriptions; name the table otherwise". Either of these avoids that whole class of problem, and is cheapest to decide before step 4 writes table placeholders into the shipped data:
- keeping the two kinds apart in the syntax, for example a prefix for tables;
- letting built-ins win, with a warning.
| { | ||
| return level; | ||
| } | ||
| if (const LevelBonusTable* table = profiles.FindTable(name)) |
There was a problem hiding this comment.
Non-blocking. FillPlaceholders runs every frame while an item is hovered. It parses the braces again and finds each table by comparing its name with every table of the stat file. D26 says names are resolved when loading, so drawing never looks them up.
Resolving each placeholder to a built-in id or a table index when the data loads (and when the stat file changes in the editor) would leave drawing a lookup by index. It would also turn a removed or renamed table into an error at that point, instead of raw text in the tooltip.
| { | ||
| // In the UI's language, also when the item names stay English ("Translate item names" off). | ||
| const std::wstring text = Core::Text::FromUtf8(line.text.Get(I18N::GetCurrentLocale())); | ||
| const std::wstring text = Core::Text::FromUtf8(Data::Items::FillPlaceholders( |
There was a problem hiding this comment.
Non-blocking. The description says the tooltip fills in the values after it picks the UI language's text and before the line wraps. But the tooltip tests only call WriteItemDescriptionLines with level 0 and texts without placeholders. A later change that fills after wrapping, or passes the wrong level, would fail no test. A test_item_descriptions case would pin it, using the existing FiveUnitsPerLetter: {level} at +15, plus a table placeholder, plus a filled value that wraps.
|
|
||
| A description text can show values of the item: `{name}` is replaced by the | ||
| value of `name` for the hovered item, e.g. | ||
| `"Increases damage by {damageIncreaseWeapons}% at +{level}"`. Item texts |
There was a problem hiding this comment.
Non-blocking. damageIncreaseWeapons is the weapons' flat damage bonus per + level (0, 3, 6, … 57, 66), not a percentage. A data author copying this example would write "Increases damage by 66%" for a +15 weapon whose damage actually rises by 66. The PR description's own wording fits: "a weapon deals {damageIncreaseWeapons} more damage".
| | `tradable`, `droppable`, `storable`, `sellable`, `personalShopSellable`, `repairable` | What a player may do with the item; see [Rules](#rules). Only `false` is written | | ||
| | `droppableWhileRented`, `personalShopSellableWhileRented`, `sellableWhenRentalExpired` | What changes for a rented item; see [Rules](#rules) | | ||
|
|
||
| ### Description placeholders |
There was a problem hiding this comment.
Non-blocking. This section extends Descriptions, which now has its own section further down (after Flight equipment), but it sits between the fields table and Tags. Moving it right after Descriptions keeps the description docs in one place. The link from the text bullet already points here.
| } // namespace | ||
|
|
||
| // The field of a description line's text in one language, as the item file writes it. | ||
| std::string DescriptionTextField(size_t line, std::string_view locale) |
There was a problem hiding this comment.
Non-blocking. DescriptionTextField and ListPlaceholders are defined after the file's anonymous namespace closes. That makes them external functions of Data::Items that no header declares, while every other helper in this file is file-private. Another translation unit with its own ListPlaceholders(const std::set<std::string>&), a likely name once the layout steps add their own checks, would then hit a duplicate-symbol link error. Moving both into the anonymous namespace avoids it.
| { | ||
| statProfiles = ItemStatProfiles(stats.file); | ||
| ValidateStatProfiles(result.items, statProfiles, GetItemStatsFile().generic_string(), result.issues); | ||
| ValidateDescriptionPlaceholders(result.items, statProfiles, GetItemStatsFile().generic_string(), result.issues); |
There was a problem hiding this comment.
Non-blocking. ValidateDescriptionPlaceholders is wired into Load, Save Items (Validate) and Import from bmd. The tests call it directly, but none checks that an unknown placeholder actually stops Load or blocks Save. If one of the three calls gets lost in a later refactor, a typo in custom data loads silently and draws as raw text. A case like the existing "Item data errors stop loading", with one description naming an unknown table, would cover Load.
| ValidateExcellentBonus(definition, issues); | ||
| ValidateExcellentRaisesLevel(definition, issues); | ||
| ValidateRequirementsByLevel(definition, issues); | ||
| ValidateDescriptionPlaceholderSyntax(definition, issues); |
There was a problem hiding this comment.
Non-blocking. ValidateItems now runs ValidateDescription (#813: the English text, language codes) and ValidateDescriptionPlaceholderSyntax (braces, the translations' placeholders). Each walks every line and translation on its own. One ValidateDescription that checks a line's texts once keeps the per-line checks together as the later steps add more (shared texts, conditions, colors).
…Formulas knows the descriptions that name a table Tables are named apart from the built-in values, so a later built-in can't clash with a stat file's table, and a table may have any name. A bare table name gets the table: form as its suggestion. Item Formulas counts the item descriptions that name a table with its stats, lists them on the table, keeps Remove off while one names it, and a rename renames the table in those descriptions in every language (D26). Also: the description's text checks run in one pass, the file-private helpers are file-private, one spelling of a description text's field, tests that an unknown placeholder stops Load and Save Items, and that the tooltip fills the values in for the item's level before the line wraps. The placeholders docs follow the Descriptions section, with a flat bonus in the example.
|
Thanks for the thorough review — all addressed, carried through the stack (#818–#836): Required
Built-ins vs tables: tables are now written Also done: one pass for the description text checks, the two helpers are file-private, one spelling of the description field, tests that an unknown placeholder stops Load and blocks Save Items, a test that the tooltip fills values for the item's level before wrapping, the example uses the flat bonus correctly, and the placeholders section follows Descriptions. Not in this PR: resolving placeholder names at load instead of each frame. It's a bigger change, and I'd like to do it as its own PR after the stack. |
|
@Mosch0512 ready for another look when you have a moment. |
…so next to a built-in value
The hint for an unknown placeholder tried the known names first and the
table: form only when they gave nothing. A table whose name is close to a
built-in value, written without table:, was then told it meant the
built-in: {levels} for the table "levels" got "did you mean {level}?",
and following that hint shows the item's + level instead of the table.
A bare name that is a table's name now gets that table's placeholder.
The stat file test for a table named "level" only checked that the name
had no error, while the changed file still failed to read: its profile
named the old table. It now names the renamed table too and reads
without errors. A placeholder test shows {level} and {table:level} side
by side with such a table: the item's + level and the table's value.
…table: form The header comments still described one namespace for built-ins and tables, and ValidateItems' and ValidateNameLocales' comments mentioned item names only. They now say that a table is "table:" and its name, and that the description texts are checked there too.
|
On resolving the placeholder names at load instead of each frame: before starting it, I measured what it could save, at the end of the stack (#836). Every item at every level 0–15, three rounds, for a level 400 Blade Master (43,728 tooltips, Windows x64 Release):
So even if resolving at load removed all of the description and name lines' work, a hovered tooltip would gain about half a microsecond of a 16.6 ms frame, and the ground labels and windows that draw item names stay in the low microseconds with dozens of items. I'd rather not add the cache and its invalidation (items, stat file, tooltip file, language) for that. The other half of the suggestion, a removed or renamed table turning into raw text, is covered now: an unknown placeholder stops Load and blocks Save Items, Item Formulas keeps Remove off while a description names the table, and a rename carries into the descriptions. Only a stat file edited by hand while the game runs could still show If profiling ever points at the tooltips, the plan is written down as step 5e in #815. |
|
Thanks, the fixes look good. I checked them and ran the tests locally. I pushed three small commits on top, so please pull before your next push here:
#818 has the same hint lines unchanged, so they should apply cleanly as you carry them up the stack. |
This step's own changes: compare with #816. The diff of this PR also holds every earlier step of #815, since a PR from a fork can only target this repository's branches.
Summary
Step 3 of 6 of #815 (data-driven item tooltips). Stacked on #816, which is stacked on #813: only the last commit,
feat(items): named placeholders in item descriptions, is this step, and this PR will be rebased as the ones below it merge.A description text can show values of the item:
{name}is replaced when the tooltip draws it.level, the item's + level;Stats/ItemStats.json(refactor(items): item stats and requirements by level from data, stat profiles (phase 3c) #801), by its name: its value at the item's + level.damageIncreaseWeaponsis 3 at +1 and 66 at +15.{and}belongs to a placeholder and there is no escape. I checked this against every tooltip text of the original client and every Game text in the 11 languages: none has a brace.Checks
They run when the item data loads, in the save checks and in Import from bmd, so a wrong placeholder in the shipped data fails the tests. All are errors:
description[0].text, or.text.<lang>for a translation){levelhas a "{" without its "}"{}has an empty placeholder "{}"level}has a "}" without its "{"{wing damage}has "{wing damage}": a placeholder is a name of letters and digitshas the placeholders {level}, {wingDamge}, the English text {level}, {wingDamage}{damgeIncreaseWeapons}has the unknown placeholder {damgeIncreaseWeapons}; did you mean {damageIncreaseWeapons}?(the nearest name at most two letters away){durability}has the unknown placeholder {durability}; a placeholder is a built-in value ({level}) or a table of ItemStats.jsonThe stat file may not have a table named like a built-in value (
tables[0].name: "level" is a built-in placeholder of the item descriptions; name the table otherwise).Code
Data/GameData/ItemData/ItemDescriptionPlaceholders.{h,cpp}:ScanPlaceholders,GetPlaceholderValue,FillPlaceholders,SuggestPlaceholder, and the built-in names.ItemStatProfiles::FindTable/GetTableNames.ValidateItems;ValidateDescriptionPlaceholders, which needs the stat file, so it runs whereValidateStatProfilesruns (load, save, bmd import).RenderItemInfo) fills the values in.docs/item-data.md.No shipped text uses a placeholder yet. The first ones come with step 4: the wing lines, over new
ItemStats.jsontables. The values of the stat lines themselves ({damageMin},{defense},{requiredStrength}, …) come with step 5, which moves those lines into data. #815 lists the planned names.Screenshots
The equipped Divine Sword of Archangel +15 with the example description above. The description is only in the screenshot's local item data, not in this PR.
{level}gives 15 and{damageIncreaseWeapons}gives 66.Related issues
Step 3 of #815. Builds on #816 (step 2) and #813 (step 1).
Checklist
docs/CODING_RULES.md.docs/build/README.md).test_item_description_placeholders.cpp(reading, values, suggestions, every error, translations), "The shipped descriptions' placeholders are built-in values or stat file tables", and the stat file's built-in name error.