diff --git a/docs/item-data.md b/docs/item-data.md index 9e39a6afe4..1a40edd59d 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -7,7 +7,7 @@ group; the item models are in `src/bin/Data/Items/Models/` (see database; `Item_.bmd` is no longer read by the game. The data matches OpenMU's `ItemDefinition` where both sides have the same -fields, so it can later be exchanged with the server. +fields, so it can be exchanged with the server (see [Item Sync](#item-sync)). --- @@ -17,6 +17,7 @@ fields, so it can later be exchanged with the server. |---|---| | `Group00_Sword.json` … `Group15_Etc.json` | 0–15, named after the `ITEM_GROUP_*` constants | | `Stats/ItemStats.json` | What the + level, excellent options, ancient sets and option level add to the stats and requirements; see [Item stats by level](#item-stats-by-level) | +| `Sync/KnownDifferences.json` | Not read by the game: the differences from the server that MuEditor's Item Sync leaves unchecked; see [Item Sync](#item-sync) | The file name is only a convention; the group comes from the file content. @@ -117,7 +118,7 @@ variants or option words. An online keyword need not select the same items offli | `wingTier` | Only for wings: `small`, `first`, `second`, `third`. The wing formulas (defense, defense per level, damage increase, absorption) depend on it; excellent second and third tier wings get no extra durability, and the chaos mix knows a wing's tier from it | | `weaponKind` | Only for weapons and shields: `sword`, `glove`, `axe`, `mace`, `spear`, `bow`, `crossbow`, `staff`, `book`, `shield`, as OpenMU's item groups 0 to 6: swords and the Rage Fighter's gloves (group 0; gloves are the items only the Rage Fighter may use), axes (1), maces and sceptres (2), spears (3), bows and crossbows (4: bows have the slot `offHand`, crossbows `mainHand`; arrows and bolts have no weapon kind), staffs, sticks and books (5: books have the slot `offHand`) and shields (6). While an item has the weapon kind its group gives, the bmd import and the item editor (also when it moves an item to another number) keep it matching the group, the slot and, in group 0, the class requirements (a group gives its kind only on that kind's slot, and in group 4 only to two-handed items), so a new weapon gets one too; a kind written in the file that differs from the group's stays as long as it differs. Which bow or crossbow a character holds (its animations, arrows and damage), the elf's arrows-only rule, a spear's reach and Impale, the shield defense set option, the bonus of leveled arrows and bolts, a staff's damage (it counts for the left hand), which right-hand weapons a summoner may hold in the left hand (not staffs or books), the character window's wizardry line, the tooltip's magic power line, the knight skills that need a weapon without a staff or book, Death Stab, Death Cannon and Spiral Slash, the weapon in the left hand that adds its magic options, the dual wield of Dark Knights, Magic Gladiators and Rage Fighters, and the weapon that moves into an empty right hand depend on it | | `potionPriority` | Only for potions with the `healingPotion`, `manaPotion`, `complexPotion` or `shieldPotion` tag: which one of a kind the game takes first, the higher the earlier (0 or left out = last; equal ones take the higher group and number first). The shipped potions count up with their size, so the largest is taken first. A client-only field (OpenMU has none); the bmd import keeps it | -| `statProfile` | The item's profile in the [item stat file](#item-stats-by-level): what its + level, excellent options, ancient set and option level add to its stats and requirements. `"default"` names the default. The [profile rules](#item-stats-by-level) of the item stat file say which profile an item's data gives it: in the shipped file `sceptre` for the items with the `sceptre` tag, `book` for summoner books, `shield` for shields, `weapon` for the other items with a weapon kind (not arrows and bolts), `armor` for helms, armour, pants, gloves and boots, `scroll` for the items of group 15 with requirements, `helper` for the items of groups 13 and 14 with a base level requirement (`requirements.level`; the Transformation Ring's comes from `requirementsByLevel`, so it has the default), and a wing tier's profile for wings (`firstTierWing`, `secondTierWing`, `secondTierCape` for the second tier capes, `thirdTierWing`, `smallWing`). While an item has the profile its data gives, the bmd import and the item editor keep it matching (also when it moves an item to another number); a profile written in the file that differs from it stays as long as it differs: the Horn of Fenrir's data gives `helper`, and it names `"default"`. A left-out profile always follows the data; while the data gives one, the item has the default and loading warns about it, as for item files saved before the field. An item whose data stops giving its profile loses the profile's terms: a bow made one-handed, which is then no bow, loses the `weapon` profile's strength per option level | +| `statProfile` | The item's profile in the [item stat file](#item-stats-by-level): what its + level, excellent options, ancient set and option level add to its stats and requirements. `"default"` names the default. The [profile rules](#item-stats-by-level) of the item stat file say which profile an item's data gives it: in the shipped file `sceptre` for the items with the `sceptre` tag, `book` for summoner books, `shield` for shields, `weapon` for the other items with a weapon kind (not arrows and bolts), `armor` for helms, armour, pants, gloves and boots, `scroll` for the items of group 15 with requirements, `orb` for the items of group 12 with stat requirements (the orbs, crystals and the Dark Lord's scrolls), `helper` for the items of groups 13 and 14 with a base level requirement (`requirements.level`; the Transformation Ring's comes from `requirementsByLevel`, so it has the default), and a wing tier's profile for wings (`firstTierWing`, `secondTierWing`, `secondTierCape` for the second tier capes, `thirdTierWing`, `smallWing`). While an item has the profile its data gives, the bmd import and the item editor keep it matching (also when it moves an item to another number); a profile written in the file that differs from it stays as long as it differs: the Horn of Fenrir's data gives `helper`, and it names `"default"`. A left-out profile always follows the data; while the data gives one, the item has the default and loading warns about it, as for item files saved before the field. An item whose data stops giving its profile loses the profile's terms: a bow made one-handed, which is then no bow, loses the `weapon` profile's strength per option level | | `twoHanded` | Weapon needs both hands | | `skill` | Skill the item gives | | `level` | Item level (the drop level); also used in price and damage calculations | @@ -129,7 +130,7 @@ variants or option words. An online keyword need not select the same items offli | `attackType` | Attack type used by the client | | `sellValue` | Value factor used by the price calculation of some items | | `buyPrice` | Fixed price; when set, it replaces the calculated price | -| `requirements` | `level`, `strength`, `dexterity`, `energy`, `vitality`, `leadership` | +| `requirements` | `level`, `strength`, `dexterity`, `energy`, `vitality`, `leadership`. The stat requirements of the orbs, crystals and scrolls (groups 12 and 15, without a slot) are the values the game asks for, as OpenMU stores them: their profiles keep them as they are. The original client's bmd files hold weights for them, which Import from bmd turns into the values and Export as bmd and Export as S6E3 turn back (the smallest weight that gives each value). Which ones convert follows the item's profile: a requirement it keeps as it is while the default grows it (an orb given the default profile holds weights like any item) | | `requirementsByLevel` | A requirement for each item level 0 to 15 (one number for all of them, or 16), instead of the [item stat file's](#item-stats-by-level) formula; above +15 the +15 value counts. The Orb of Summoning has `{"energy": [30, 60, 90, 130, 170, 210, 300, 500, 0, ...]}` and the Transformation Ring `{"level": [20, 20, 20, 50, ...]}`. The base value of that requirement is not used. The bmd import keeps it | | `classRequirements` | Per class (`darkWizard`, `darkKnight`, `fairyElf`, `magicGladiator`, `darkLord`, `summoner`, `rageFighter`): 0 = cannot use, otherwise the class level needed | | `resistances` | `ice`, `poison`, `lightning`, `fire`, `earth`, `wind`, `water` | @@ -433,7 +434,8 @@ The shipped profiles: | `armor` | 5 strength per option level: helms, armour, pants, gloves and boots | | `book` | Energy `20 + base * (drop level + item level) * 3 / 100`; 5 strength per option level: summoner books | | `helper` | Level requirement +4 per level: the items of groups 13 and 14 with a base level requirement (in the shipped data the rings, pendants, pets and Loch's Feather of group 13); the Horn of Fenrir names `"default"` | -| `scroll` | Level requirement +4 per level; energy from the level requirement (`20 + base * level requirement * 4 / 100`) when the item has one: the skill scrolls and parchments of group 15 | +| `scroll` | Level requirement +4 per level; the stat requirements as stored (`"base"`): the skill scrolls and parchments of group 15 | +| `orb` | The stat requirements as stored (`"base"`): the orbs, crystals and the Dark Lord's scrolls of group 12 | | `sceptre` | No magic power per level after halving; 5 strength per option level: the Dark Lord's sceptres | | `shield` | Defense +1 per level (`defenseIncreaseShields`), no excellent part, ancient part `(defense + level) * 20 / d + 2` | | `firstTierWing` | Defense from `defenseBonus1stWings`; level requirement +4 per level | @@ -443,9 +445,8 @@ The shipped profiles: | `thirdTierWing` | Defense from `defenseBonus3rdWings`: 4 per level up to +9, then 5 to 10 more per level | The shield also adds 5 strength per option level. Items without a profile -or with `"default"` (arrows and bolts, jewels, potions, the orbs, crystals -and scrolls of group 12, the Horn of Fenrir, …) have the default: their -level requirement does not grow. +or with `"default"` (arrows and bolts, jewels, potions, the Horn of Fenrir, +…) have the default: their level requirement does not grow. The shipped profile rules, in order, with the items each gives its profile: @@ -463,10 +464,11 @@ The shipped profile rules, in order, with the items each gives its profile: | 10 | `secondTierWing` | slot `wings`, wing tier `second` | 5 | | 11 | `thirdTierWing` | slot `wings`, wing tier `third` | 7 | | 12 | `default` | slot `wings` (wings without a tier) | 0 | -| 13 | `scroll` | group 15, any base requirement | 36 | -| 14 | `helper` | groups 13 and 14, a base level requirement | 32 | +| 13 | `orb` | group 12, a base stat requirement | 12 | +| 14 | `scroll` | group 15, any base requirement | 36 | +| 15 | `helper` | groups 13 and 14, a base level requirement | 32 | -The other 342 items match no rule and have the default. Rule 14 reaches the +The other 330 items match no rule and have the default. Rule 15 reaches the Horn of Fenrir, which names `"default"`. **Errors** in the file stop the start like errors in the item files: a file @@ -886,9 +888,8 @@ shows it in the others. the base value back; excellent and ancient items get no higher stat requirement from them, while excellent options still raise a level given by level and the option level still adds the strength its stat profile - gives per option level; a scroll's energy, - whose formula takes the level requirement, follows a level given by - level). Below them, what the game asks at each + level + gives per option level; a stat requirement whose formula takes the level + requirement, follows a level given by level). Below them, what the game asks at each + level (the `/` window shows the normal item), also as excellent, ancient or with an option level, while a value is still being typed. Then the class steps (0: the @@ -905,6 +906,8 @@ shows it in the others. a problem of an item selects it and opens the tool of its field; an arrow key onto it only selects the item. A click on a problem of the item stat file (a profile or a table nothing uses) opens Item Formulas at it. +- **Item Sync**: Export and Import of the items and item formulas with an + OpenMU server, see [Item Sync](#item-sync). - **Item Translations**: the names in all languages, see [translation-system.md → In the item editor](translation-system.md#in-the-item-editor). @@ -1054,9 +1057,167 @@ applied, **New stat profile**, **New level bonus table**, **Copy**, the items keep the profile they name, and those it no longer gives count as picked. -The server keeps its own formulas until they are synced, so what it checks -and computes can differ from what changed formulas give. Items your -character already has keep their values until they are made again. +The server keeps its own formulas until they are synced (see +[Item Sync](#item-sync)), so what it checks and computes can differ from +what changed formulas give. Items your character already has keep their +values until they are made again. + +### Item Sync + +Item Sync exchanges the items and the item formulas with an OpenMU server +through a file, the *item exchange file*. The server stays authoritative: +the client never writes to it. (OpenMU's export and import of the file, and +Update from server, which downloads it, are later steps of phase 7; see the +[design](superpowers/specs/2026-09-25-data-driven-items-design.md).) + +- **Import…** opens a file and lists how it differs from the client's + items and formulas: what the import would add to the client, change in + it or remove from it. **Apply the checked changes** first says what it + changes ("3 items added, 5 changed, 1 removed; 2 formula entries + change"; what is not checked or not listed stays as it is), then takes + the checked rows into the client as one step of the undo history; Save + Items writes the files, as for any edit. +- **Export…** writes the client's items and formulas into the file it + picks, without a diff: OpenMU's import page shows what the file changes + on the server, and Import shows what one changes in the client. A new + file is written right away ("Exported 911 items and all formulas to + …"). A file that is there is replaced after a confirmation that says what + changes in it ("3 items added, 5 changed, 1 removed; 2 formula entries + change"), or that it cannot be read as an exchange file; a file that has + the client's data already is left as it is. The written file says + `"source": "MuMain"`. +- **Compare two files** (folded under the path field) compares two exchange + files, e.g. a client export with a server export, without the client's + data: pick file A and file B (**Swap** swaps them), then **Compare the + files**. The lists say what changes from file A to file B (Added, + Changed, Removed under "From A to B", the values under "File A" and + "File B"; an item only file A has lists file A's values), in every + language, also one only one file has. An item a file has twice is a + locked row that names that file. Nothing can be applied, but a row can be + marked as a known difference. An import or a comparison of two files + replaces the other in the window. +- An import lists only what differs. The lists say how much of what was + compared differs ("Items that differ (3 of 911)"), and when nothing does, + how many are the same ("Items: all 911 are the same in the client and the + file"). When the file has exactly the client's data, the window says so + in green ("The file matches the client: nothing differs."), e.g. for a + file you just exported; change an item or the file, or pick another + file, to see differences again. "The same" means that there is nothing to + take: a name in a language the file lacks, or one the client's UI does + not show, is not compared (no language is removed). +- After applying, the window compares again: what was applied no longer + differs and leaves the list. A green line above says what was applied; + what was not checked stays listed and unchecked. An import is refused + when an item it would add, resize, give another slot or remove is worn or + in an item window by then (compare again). +- The path of an import can also be typed into **Exchange file**; + **Compare again** compares with it. **Apply the checked changes** is off + while nothing is checked, while the path field holds another file than + the one compared, and while the client's items or formulas changed since + the comparison (the window says which; compare again). + +**The file** has the layout of the item files: items sorted by group and +number, fields with their default value left out, LF line endings. + +```json +{ + "formatVersion": 1, + "source": "OpenMU", + "server": "…", + "gameVersion": "season6", + "formulas": { + "dropLevelBonus": {"excellent": 25, "ancient": 30}, + "tables": […], + "default": {…}, + "profiles": […] + }, + "items": [ + {"group": 0, "number": 0, "name": {"en": "Kris", …}, "width": 1, "height": 2, …, "statProfile": "weapon"} + ] +} +``` + +- `source` is `MuMain` or `OpenMU`; `server`, only in a file from OpenMU, + names the server's installation. +- Only `season6` data is exchanged; a file of another `gameVersion` is + refused, as is a `formatVersion` the client does not know. +- `formulas` is the [item stat file](#item-stats-by-level) without its + `formatVersion` and its profile rules, which stay with the client. +- `items` has the fields both sides have, with the keys and meanings of the + item files: the group and number, the names, size, slot, skill, drop + level, durability and magic durability, damage, attack and walk speed, + magic power, defense, block rate, resistances, the requirements (also by + level), the class requirements, the prices, the [rule flags](#rules) + without the rental ones, the tags `ammunition`, `secondClassQuestItem` + and `thirdClassQuestItem`, and `statProfile` (always written: the profile + the item uses, `default` for the default), `excellentBonus` and + `excellentRaisesLevel`. +- What only the client has is not in it: the model and display data, the + description, the other tags, the wing tier, weapon kind, two-handed flag, + potion priority, attack type, magic defense and the rental flags. A file + with such a field is read with a warning and the field is left out; an + import keeps the client's. + +**The diff of an import**: + +- The **Items** list says for each item what the import does to it in the + client (Added, Changed, Removed). The box of an added or removed item + checks the whole item, the box of a changed one all its fields. A click on a row shows its fields (and selects the item in the + item tools): each field (`width`, `requirements.strength`, `name.de`, + `tags.ammunition`, …) with its value now and the file's, and a box of its + own. +- The **Formulas** list has the drop level bonus, each table, each term of + the default and of each profile (`default.damage.table`, + `profiles.scroll.requirements.energy`, …) and a profile only one side + has. A file without formulas compares none and leaves the client's as + they are. +- Checked at first: what the file changes. Unchecked: removals (also of + a formula entry only the client has), known differences (grey) and + fields with a note: the size and slot of an + item your character wears or an item window shows (they stay; such an + item is not removed either, nor added when the client has no data for it + yet), and a drop level above 255, which OpenMU keeps in a byte. +- **Check all** and **Check none** take every row; **A field in all items** + checks or unchecks one field in every changed item. +- **Names**: only the client's UI languages are taken from the file; a + language the file lacks stays as it is, and no language is removed. +- **Stat profile**: a file's profile that the item's profile rules give + leaves the item following its rules; another one becomes a pick (see + Item Basics). +- **Added items** get the client-only fields at their defaults; the + two-handed flag follows the rule of the shipped items (groups 0 to 5, a + hand slot, two cells wide or more) and the weapon kind follows the group, + as for Add item. The row says when the item has no model entry yet (link + one in Item Model; the browser's "without a model" filter lists it) and + when its profile rules give another profile than the file (a wing tier + or a tag such as `cape` may be missing). +- **Removed items** go with their model entry, as Remove item does; the row + lists what goes with the item and what uses it at its number. +- An item a file has more than once (OpenMU does not keep the group and + number unique) is a locked row: the import leaves it as it is. +- Before the import is applied, the data as it would be gets the checks of + a save. With errors nothing changes; the errors are listed and the rows of + their items are unchecked. + +**Known differences** are fields and formula entries meant to differ +between the client and the server. Ticking **Known** on a field, on an +added or removed item or on a formula entry leaves it unchecked and grey in +every comparison. They are kept in `Data/Items/Sync/KnownDifferences.json` +next to the game, written when one is ticked; copy it to +`src/bin/Data/Items/Sync` with the item files to share it. `""` stands for +the whole item: + +```json +{ + "formatVersion": 1, + "items": [ + {"group": 13, "number": 4, "fields": ["", "repairable"]} + ], + "formulas": [ + "default.requirements.leadership" + ] +} +``` ### Import from bmd / Export as bmd diff --git a/docs/superpowers/specs/2026-09-25-data-driven-items-design.md b/docs/superpowers/specs/2026-09-25-data-driven-items-design.md index f77be4b255..bae509f5ac 100644 --- a/docs/superpowers/specs/2026-09-25-data-driven-items-design.md +++ b/docs/superpowers/specs/2026-09-25-data-driven-items-design.md @@ -17,8 +17,8 @@ code**: code reads it instead of hardcoded item lists. - Runtime lookups are **as fast as hardcoded checks** (see section 1). - The data model is **equivalent to OpenMU's `ItemDefinition`** where both - sides overlap, so data can be moved between client and server with - file-based import and export on both sides. + sides overlap, so data can be moved between client and server: by file on + both sides, and by an update from the server in MuEditor (phase 7). - Items can be **added and removed** on client and server, and linked to their model data with MuEditor tools. - Every data field can be edited in **focused MuEditor editors** (several @@ -38,7 +38,7 @@ share with items) is planned in the | D1 | Data format | JSON files as the source of truth, loaded once at startup into flat tables. Runtime speed must be as close to hardcoded as possible; a binary cache is added only if startup parsing is measurably too slow. Chosen over a database or SQL text files; see section 2. | | D2 | Exchange format with OpenMU | JSON keyed by `(Group, Number)`, not SQL `UPDATE` statements (SQL would break whenever OpenMU's database schema changes). | | D3 | Server rules | Check what OpenMU already enforces (see "Server-side checks today"), then extend OpenMU's tables or add new ones following OpenMU's usual workflow. | -| D4 | Sync | File-based import and export on **both** sides. No live connection or pull button. | +| D4 | Sync | File-based import and export on **both** sides, each import with a diff the user checks by hand (an export writes its side's data), plus a read-only Update from server in MuEditor (phase 7, section 6). The client never writes to the server. | | D5 | Editors | Several focused editors instead of one big table. | | D6 | Add/remove | Items can be created and removed on client and server; MuEditor tools link a new item to its model data. | | D7 | File layout | One JSON file per item group. Splitting the mixed groups 12–15 into category files was dropped: with new groups (D21) the group *is* the category, so the file layout is decided together with phase 12. | @@ -111,9 +111,12 @@ this branch (D16). `Group`, `Number`, `Width`, `Height`, `Durability`, `Value`, `DropLevel`, `MaximumItemLevel`, `ItemSlot`, `Skill`, `QualifiedCharacters`, `Requirements`, `BasePowerUpAttributes`, `PossibleItemOptions`, - `PossibleItemSetGroups`, `MaximumSockets`, `ConsumeEffect`, and the flags + `PossibleItemSetGroups`, `MaximumSockets`, `ConsumeEffect`, + `MaximumDropLevel`, `PetExperienceFormula`, `DropItems`, the flags `IsAmmunition`, `IsBoundToCharacter`, `IsQuestItem`, `DropsFromMonsters`, - `StorageLimitPerCharacter`. + `StorageLimitPerCharacter`, and since PR A the six rule flags + `IsTradable`, `IsDroppable`, `IsStorable`, `IsSellableToNpc`, + `IsPersonalStoreSellable` and `IsRepairable`. - Names are `LocalizedString`: `"English||de=Deutsch||fr=Français"`, English (neutral) first. - Identity: `(Group, Number)`. Initialization creates deterministic GUIDs @@ -124,7 +127,9 @@ this branch (D16). - Data changes ship as `UpdatePlugInBase` plug-ins. The admin panel's generic config grid (`EditConfigGrid`) can already **add and delete** item definitions; deleting fails while something still references the - item. There is no file import or export for config yet. + item. The admin panel can download the whole configuration as JSON and + back up the database; there is no export or import of single items yet + (phase 7, section 6). ### Server-side checks today @@ -287,8 +292,8 @@ Each field belongs to one of two groups: | Name | `LocalizedString`, same format as OpenMU | yes | | Size and slot | width, height, item slot, two-handed | yes | | Stats | damage, defense, speed, durability, requirements, resistances, class requirements, value, skill | yes (mapped to OpenMU requirements and power-ups) | -| Server flags | ammunition, bound to character, quest item, drops from monsters, storage limit | yes | -| Rule flags | tradable, droppable, storable, sellable to NPC, personal-shop sellable, repairable, usable/consumable, equippable | yes, after OpenMU is extended (D3) | +| Server flags | ammunition, quest item | yes; bound to character, drops from monsters and storage limit are server-only | +| Rule flags | tradable, droppable, storable, sellable to NPC, personal-shop sellable, repairable | yes (OpenMU has them since PR A) | | Categories/tags | wing, flying, mount, pet, potion, jewel, ticket, cash shop, event, … | client-only at first; shared where OpenMU gets matching data | | Model | model file, texture folders, cloth flag (in the model files, D22) | client-only | | Rendering | inventory offset, rotation and scale; ground rotation and scale; glow color, render style, effects (D24) | client-only | @@ -317,24 +322,237 @@ Each field belongs to one of two groups: - Stats are no longer duplicated per language: one data set, only names are translated. -### 6. OpenMU sync (file-based, both sides) - -- **Server is authoritative.** The client never writes to the server, not - even in editor mode. -- One **exchange file format** (JSON, with a format version) keyed by - `(Group, Number)`, containing only the shared fields. -- Item sets, drop settings and option definitions get their own exchange - files later (D11, D14). The item file only links to the option groups an - item can have. -- **Client → server:** MuEditor exports the file; the admin imports it in a - new OpenMU admin panel import page. -- **Server → client:** a new OpenMU admin panel export page writes the same - file; MuEditor imports it and updates the shared fields. -- Import on both sides shows a **diff** (new, changed, removed items) and - applies only what the user confirms. -- Removals in an import are only applied when confirmed; on the server they - fail if characters still own the item (existing OpenMU behavior). -- The OpenMU import/export pages are a separate OpenMU PR. +### 6. OpenMU sync (phase 7) + +- **The server is authoritative.** The client never writes to the server, + not even in editor mode. Item data moves by file in both directions, and + MuEditor can also download it from the server (Update from server). +- **What moves:** the item fields both sides have, and the item formulas: + everything in `Stats/ItemStats.json` except the profile rules, so a + formula OpenMU changes reaches the client too. +- **Every import shows a diff** (added, changed and removed items and + formula entries, field by field) that the user checks by hand; only the + checked rows are applied, after a confirmation that says what changes. + The same holds for OpenMU's import page. An export shows no diff: it + writes the client's data, and the side that imports the file shows what + it changes (a diff there and one at export only confused). +- Item sets, drop settings and option definitions get exchange files of + their own later (D11, D14). +- Phase 7 has five steps, one PR each: **1** the exchange file with Export + and Import in MuEditor (MuMain); **2** the item formulas as data in OpenMU; + **3** OpenMU's read-only export endpoint; **4** Update from server in + MuEditor (MuMain); **5** OpenMU's import page. Steps 2, 3 and 5 are phase + B's part. + +**The exchange file.** + +- One UTF-8 JSON file in the layout of the item files: fixed key order, + items sorted by (group, number), LF line endings. +- Header: `formatVersion` (1), `source` (`"MuMain"` or `"OpenMU"`), `server` + (only in a file from OpenMU: an id its export creates once per + installation and keeps, step 3; the id of OpenMU's game configuration + cannot serve, as its initialization gives every installation the same + one) and `gameVersion` (`"season6"`; a file of another version is + refused, D8). +- `formulas`: the item stat file without its `formatVersion` and + `profileRules`: `dropLevelBonus`, `tables`, `default` and `profiles`, with + the keys and meanings of `Stats/ItemStats.json`. +- `items`: each item with the shared fields, in the keys and meanings of the + item files (section 3, `docs/item-data.md`): + - `group`, `number`, `name`, `width`, `height`, `slot`, `level`, + `durability`, `magicDurability`, `skill`; + - `damageMin`, `damageMax`, `attackSpeed`, `walkSpeed`, `magicPower`, + `defense`, `blockRate`, `resistances`; + - `requirements`, `requirementsByLevel`, `classRequirements`; + - `sellValue`, `buyPrice`; + - `tradable`, `droppable`, `storable`, `sellable`, `personalShopSellable`, + `repairable`; + - the tags `ammunition`, `secondClassQuestItem`, `thirdClassQuestItem`; + - the formula fields `statProfile` (the profile the item uses: the one its + rules give or the picked one; `"default"` for the default; always + written), `excellentBonus` and `excellentRaisesLevel`. +- Not in the file: what is client-only (the model and display data, D22; + `description`; the other tags, `wingTier`, `weaponKind`, `twoHanded`, + `potionPriority`, `attackType`, `magicDefense`, the rental flags) and what + is server-only (`DropsFromMonsters`, `MaximumDropLevel`, `MaximumItemLevel`, + `IsBoundToCharacter`, `StorageLimitPerCharacter`, `PetExperienceFormula`, + `ConsumeEffect`, `MaximumSockets`, the item options and sets, the drop + items and the power-ups the client has no field for, e.g. `CanFly`). +- The file uses the client's names and meanings; OpenMU converts to and from + its model (steps 3 and 5). Its export and import report the cases where + that is not exact: + - hand slots: OpenMU gives one-handed weapons a Dark Knight, Magic + Gladiator or Rage Fighter may use both hands ({0, 1}); the file has + `mainHand`, and OpenMU's import gives {0, 1} by the same rule; + - OpenMU's one `Value` is the client's `buyPrice` in groups 12 and 15 and + its `sellValue` elsewhere (a byte in the client); + - OpenMU's `Durability` of an item without a slot is its stack size, and + its `Skill` of a scroll or orb is the skill it teaches; neither is + exchanged; + - OpenMU's drop level is a byte (the Horn of Fenrir has 300 in the client); + - OpenMU has one `IsQuestItem` for the client's two tags + `secondClassQuestItem` and `thirdClassQuestItem` (the same eight items); + - a class set with a gap, a requirement by level that goes down, magic + power's rounding (OpenMU halves each part, the client the sum), the + `@src` and `@stamp` parts of OpenMU's names and its `;` level variant + names (three items), and a (group, number) that exists twice on the + server (a locked row of the import, which leaves it as it is; an + export replaces such a file after its confirmation). +- A field left out has its default, as in the item files. A reader warns + about keys it does not know and refuses a `formatVersion` it does not + know; the version only goes up for a change that older readers would + misread. + +**The orbs, crystals and scrolls** (the 44 items of groups 12 and 15 that +have stat requirements and no slot) keep their requirements as the values +the game asks for, as OpenMU stores them, instead of a weight that the drop +level formula grows. Their stat profiles write `"base"` for the stat +requirements: the `scroll` profile of group 15, and a new `orb` profile with +a rule of its own for group 12. The values are converted once; a test pins +them and checks that the game asks for the same at +0 (above +0 the value +stays as it is, as on OpenMU; the game shows these items only at +0). The +Orb of Summoning keeps its energy by level. The bmd files of the original +client hold weights for these items, so Import from bmd turns them into the +values with the original client's formulas, and Export as bmd and Export as +S6E3 turn the values back into the smallest weight that gives them (the +nearest one, with a note, when none gives a value exactly). Which +requirements convert follows the item's stat profile, as in the game: one +the profile keeps as it is while the default grows it. + +**The Item Sync window** (MuEditor, `_EDITOR` builds only; the Item Sync +tool of section 9): + +- **Export…**: a save dialog picks the file, and the client's items and + formulas are written into it, without a diff. A file that is there is + replaced after a confirmation with what changes in it (items added, + changed and removed, formula entries that change; or that it cannot be + read as an exchange file); a file that has the client's data already is + left as it is. +- **Import…**: an open dialog picks the file; the diff compares it with the + client's data, and the checked rows are applied to the client after a + confirmation with what they change (items added, changed and removed, + formula entries; what is not checked or not listed stays as it is). +- **Update from server** (step 4) downloads the server's file and goes on as + Import. +- **Compare two files**: a read-only diff of two exchange files (e.g. a + client export and a server export), what changes from file A to file B, + in every language; its rows can be marked as known differences. Only the + client has it: OpenMU's import page compares a file with its live data, + the client's Import a file with the client's data, and a comparison of + two files is a tool for the one who edits the items. +- The file dialogs are SDL3's (`SDL_ShowOpenFileDialog`, + `SDL_ShowSaveFileDialog`), on every platform. Their callback can run on + another thread, so it only hands the path to the window, which reads it + in its next frame. On Linux they need the desktop portal or zenity. +- **The diff** lists, for items, the added ones, the changed ones with their + fields (client value, file value) and the removed ones; for the formulas, + the drop level bonus, each term of the default and of each profile, and + each table. A checkbox per field, with a toggle per item and per column. + Checked by default: the differences of the file. Unchecked by default: + removals, inexact fields and known differences. (Local edits and + conflicts come with the last server state in step 4.) +- **Known differences** are the items' fields and formula entries meant to + differ between the client and a server (e.g. the Dark Horse is repairable + on OpenMU on purpose). They are kept in the repo, in + `Data/Items/Sync/KnownDifferences.json` (the game does not read it), so + they are shared and survive a new build folder; the diff shows them + greyed and unchecked, and a row can be added to the list from the diff. +- **Names:** only the client's UI languages are taken from a file; a + language the file lacks stays as it is, and no language is removed. +- **Stat profile:** a file's profile that the item's rules give leaves the + item following its rules (also when the rules give the default; only a + pick is stored as `"default"`); another one becomes a pick (section 9). + Export writes the profile the item uses. +- **Added items:** `twoHanded` follows the rule all shipped items follow + (groups 0 to 5, a hand slot, width 2 or more), the weapon kind follows the + group as for Add item, then the profile is compared with the rules. The + row flags what the rules need and the file cannot give: a wing tier, or + the tags `cape`, `sceptre` and `summonerBook`. The other client-only + fields take their defaults. A number that has a model entry but no item + keeps that model; otherwise the row says the item has no model, and the + Item Browser's "without a model" filter lists it (Item Checks gets no + check for it: 33 shipped items have no model entry, several drawn by + code). +- **Removed items** remove the item and its model entry, as Remove item + does, with its "used by" list. An item the game holds (worn or in an + item window) is not removed, and its size and slot stay; a new item of a + type the game holds (the client had no data for it) is not added. The + import checks this again when it is applied. +- **Apply** (after its confirmation): the changed items and the changed item + stat file are checked first, with the checks of a save: a row with an error is unchecked and + shows the message. Then everything checked is one step of the undo + history: the item stat file first, so the items' profiles follow the new + rules, then the items. The size and slot of a held item are left out of + the change (the item tools refuse a whole change that would move them). + Save Items writes the files, as for any edit. + +**Update from server** (step 4): + +- `GET /api/items/exchange` (step 3), with the API key in + the `X-Api-Key` header. The key comes from the environment variable + `MU_OPENMU_API_KEY` and is never written to a file or a log; the user + creates it on OpenMU's API keys page (the Viewer role is enough). +- The URL is a setting of its own in `MuEditor/MuEditor.ini` (`[OpenMU] + AdminPanelUrl`, read and written by the editor's settings like its other + sections), never in `config.ini`, which ships to players. https is + required except for `localhost`, `127.0.0.1` and `[::1]`; plain http on a + local network is an opt-in (OpenMU's all-in-one setup serves plain http + on port 80). +- The download runs on a worker thread with libcurl (already linked into + every build; its global init runs once before the thread starts), with + timeouts and a cancel; the key stays out of curl's verbose output and the + editor's log. The window shows its state and the errors (no URL, no + connection, 401 key, 403 role or setup mode, 404 no endpoint, 429 too many + requests, an unknown format). +- The last file of each server is kept as its last server state, + `MuEditor/ItemSync/.json`, on the developer's machine. It makes + the diff three-way: a field only the server changed is a server change + (checked), a field only the client changed is a local edit (unchecked, + "to export"), a field both changed is a conflict (unchecked). The request + sends its ETag, so an unchanged server answers with nothing to download. +- The remove warning names the items that were in the last server state + (section 7). + +**OpenMU** (phase B, separate OpenMU PRs): + +- **Step 2, formulas as data:** the named stat profiles, the level bonus + tables (data already), the default terms and the drop level bonus become + part of OpenMU's game configuration, and each item definition names its + profile and gets `excellentBonus` and `excellentRaisesLevel`. + `ItemExtensions.GetRequirement` and `ItemPowerUpFactory` read them, and so + does the price calculation, which has a copy of the drop level bonus of + its own (`ItemPriceCalculator`). Their defaults give exactly what today's code computes (a test for + every item, item level, excellent and ancient), with a migration and an + update plug-in. The two sides compute differently today in a few places + (see the phase 3 mapping: the order of the ancient and excellent drop + level bonus, leadership, the option level's strength, the level + requirement per item level, magic power's rounding); the first sync shows + them as formula differences, and each is decided then. +- **Step 3, the export endpoint:** `GET /api/items/exchange` in the admin + panel, read only. It takes the API key scheme alone, with a policy of its + own (the existing ones also accept the admin panel's login cookie), and + refuses while the server has no admin user yet: OpenMU lets every request + through then, its API keys page included, so anyone could make a key. Requests are + limited per key and per address, and one export is built at a time (429 + otherwise). The export is built once and kept in memory with an ETag, and + rebuilt when the configuration changes, so a repeated request costs + nothing worth noting; it reads a fresh configuration context, not the + admin panel's shared one. A page of the admin panel offers the same file + as a download, for Import without a connection. In the all-in-one setup + the admin panel runs in the game server's process: it should not be + reachable from the internet (localhost, a VPN, a firewall), or it runs as + a host of its own (the distributed setup). +- **Step 5, the import page:** an admin panel page (Administrator role) + takes the client's export and shows the same diff. The checked rows are + applied in one configuration context. A new item takes the server-only + values (maximum item level, options, sets) from a template item picked + on the page. Names keep OpenMU's `@src` and `;` parts. A removal fails + while something still references the item (existing behavior). + +**Later:** the server's item data reaching every player at game start or +in the launcher is a follow-up idea for a service of its own, hosted apart +from the game servers +([follow-up ideas](2026-10-06-possible-follow-up-ideas.md)). ### 7. Adding and removing items @@ -390,7 +608,7 @@ table: | **Model and visuals** | Model file picker for `Data\Item\*.bmd` with 3D preview, texture folder, inventory and ground display values, cloth flag, and glow, render style and effects chosen from lists (D24), each with the list of the other items that use the same look. | | **Look editor** (phase 13) | The shared look editor of the [roadmap](2026-09-29-data-driven-content-roadmap-design.md), opened for the looks of the selected item: its building blocks with a live preview, bone picking, the items that use a look and the warning before a shared look changes. | | **Item Translations** | Items × languages grid (every UI language, English first), the missing names counted per language, a filter for them, "same as English" for names that do not change. Built in phase 5, before the other tools. | -| **OpenMU sync** | Import, diff and export of the exchange file. | +| **Item Sync** (phase 7) | Export, Import and Update from server of the exchange file; Import and Update from server with a diff checked by hand (section 6). | Common features: validation before saving (duplicate ids, missing English name, missing model file), and undo for the current session. @@ -414,8 +632,8 @@ in both repos (as separate PRs, one per repo). | 4e | Clear model loading errors | Client | MuMain | 4 | One message for missing model files and textures of item models that names the item, the model entry, the texture and the searched folders. | | 5 | Translation tooling | Client | MuMain | 2 | Translations editor (items × languages), missing names counted in the editor. The names themselves moved to phase 2 (D17). Done before phase 6. | | 6 | Editors | Client | MuMain | 2–5 | Focused MuEditor tools (section 9) on the rules of D26, including add/remove items and picking the looks of an item from lists. Takes over phase 5's item list. Done. | -| 7 | Item sync, client side | Client | MuMain | 2, 6 | MuEditor import/export of the item exchange file, with diff. The client-only fields of the item files (tags at first, `description`) are left out. | -| B | Item sync, server side | Server | OpenMU | 7 (file format) | Admin panel import/export pages for the item exchange file, with diff. | +| 7 | Item sync, client side | Client | MuMain | 2, 6 | The exchange file of items and item formulas; MuEditor's Export, Import (step 1) and Update from server (step 4), the imports with a diff (section 6). The client-only fields of the item files (tags at first, `description`) are left out. | +| B | Item sync, server side | Server | OpenMU | 7 (file format) | The item formulas as data (step 2), the read-only export endpoint (step 3) and the import page (step 5), as steps of phase 7 (section 6). | | 8 | Data-driven tooltips | Client | MuMain | 2, 5 | Tooltip JSON converted from the `ItemTooltip*` files; `RenderItemInfo()` reads data; tooltip editor. Moved earlier if needed. In steps (#815): an item's own texts go into its item file (`description`, D15), shared texts and layouts into a tooltip file. | | 9 | Item option definitions | Both | MuMain + C | 2 | Option groups in their own JSON files matching OpenMU's `ItemOptionDefinition`; exchange file and editor. | | 10 | Item sets | Both | MuMain + C | 2 | Item sets in JSON matching OpenMU's `ItemSetGroup`; exchange file and editor; based on the `item-set-editor` branch. Moved earlier if needed. | @@ -548,6 +766,9 @@ server with original clients (after phases 6 and B). **A (OpenMU):** new rule fields/tables with migration, initialization, update plug-in and server enforcement, based on the mapping above. + (Done: OpenMU has had the six rule flags since MUnique/OpenMU#983, with + these values, and gives the Dark Horse `CanFly`. The exchange of phase 7 + uses the table above with that.) **3b Remaining item lists (done).** The lists of item ids outside the phase 3 files that repeat data the items already have (tags, `slot`, @@ -1012,22 +1233,33 @@ server with original clients (after phases 6 and B). ancient or how high its + level goes (OpenMU has `PossibleItemOptions`, item sets and `MaximumItemLevel`; maybe a phase after 12, once level variants are items of their own). - - Formula sync with OpenMU comes later: only the level bonus tables have - data there today (`ItemLevelBonusTable`); the requirement formula, the - excellent and ancient bonuses and the drop level bonus are C# code - (`ItemExtensions.GetRequirement`, `ItemPowerUpFactory`). A formula - exchange file of its own, close to `ItemStats.json` and keyed by stable - names, and an OpenMU change that makes the requirement formula data - (defaults equal to today's code) would let both sides share them. The - client's profile rules stay client-side; the exchange expands profiles - per item. Found while checking: 70 items have other drop levels in the - client than in OpenMU (e.g. the Lilium armour, 131 against 113), so - their requirements already differ between the two. -7. **Item sync, client side**: MuEditor import/export of the item exchange - file and diff. - - **B (OpenMU):** admin panel import and export pages for the item - exchange file, with diff. + - Formula sync with OpenMU is part of phase 7 (section 6): only the + level bonus tables are data there today (`ItemLevelBonusTable`); the + requirement formula, the excellent and ancient bonuses and the drop + level bonus are C# code (`ItemExtensions.GetRequirement`, + `ItemPowerUpFactory`), which phase 7's step 2 turns into data. The + client's profile rules stay client-side. +7. **Item sync** (section 6), in five steps, one PR each: + 1. **The exchange file, Export and Import (MuMain).** The file of items + and item formulas, its reader and writer, the Item Sync window with + the diff, the known differences, and the orbs, crystals and scrolls + keeping their requirements as values. A test takes every shipped item and the + shipped formulas to the file and back unchanged. + 2. **The item formulas as data (OpenMU).** Stat profiles, default terms + and the drop level bonus in the game configuration, each item naming + its profile; defaults equal to today's code, tested for every item and + level; migration and update plug-in. + 3. **The export endpoint (OpenMU).** `GET /api/items/exchange`, always + behind an API key, rate limited, cached with an ETag, and a download + button. + 4. **Update from server (MuMain).** The download in MuEditor, the last + server state and the three-way diff. The differences the first real + diff shows are fixed on the side that is wrong, each in a change of + its own. + 5. **The import page (OpenMU).** The client's export with the same diff; + new items take their server-only values from a template item. + + **B (OpenMU):** steps 2, 3 and 5. 8. **Data-driven tooltips** (D15): convert `ItemTooltip_`, `ItemTooltipText_` and `ItemLevelTooltip_` into JSON (line texts as `LocalizedString`, like item names), and let `RenderItemInfo()` diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 16bfde8a93..d04089b884 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2098,4 +2098,241 @@ Ein Klick auf das Problem eines Monsters wählt es aus und öffnet das Werkzeug seines Felds. + + Item-Abgleich + + + Importieren… + + + Exportieren… + + + Austauschdatei + + + Neu vergleichen + + + Importieren listet auf, wie sich eine Datei von den Items und Formeln des Clients unterscheidet, nur die Unterschiede (was auf beiden Seiten gleich ist, fehlt), und übernimmt die angehakten Werte in den Client. Exportieren schreibt die Items und Formeln des Clients in eine Datei. + + + Import aus {0} + + + Von OpenMU, Server {0} + + + {0} hinzugefügte, {1} geänderte, {2} entfernte Items; {3} Formeleinträge + + + Alle anhaken + + + Keinen anhaken + + + Ein Feld in allen Items + + + Anhaken + + + Haken entfernen + + + Geändert + + + Entfernt + + + Felder + + + Jetzt + + + Aus der Datei + + + Hinweis + + + Eintrag + + + Bekannt + + + Ein bekannter Unterschied: zwischen Client und Server gewollt, er bleibt daher ohne Haken. Gespeichert in Data/Items/Sync/KnownDifferences.json. + + + OpenMU speichert die Drop-Stufe in einem Byte (höchstens 255) + + + Es hat noch kein Modell + + + Seine Profilregeln geben {0}, die Datei {1}: eine Flügelstufe oder ein Tag fehlt vielleicht + + + Angehakte Änderungen übernehmen + + + Items importieren + + + Die Prüfungen lehnen die Änderung ab; die Zeilen der Items mit Fehlern haben keinen Haken mehr, und alle Formelzeilen, wenn die Formeln einen Fehler haben: + + + Felder von {0} + + + Item-Austauschdateien + + + Die Daten des Clients haben sich seit dem Vergleich geändert: neu vergleichen. + + + Es ist mehrmals in der Datei: im Client bleibt es, wie es ist + + + Um Unterschiede zu sehen, ein Item im Editor oder in der Datei ändern oder eine andere Datei wählen und dann neu vergleichen. + + + Die Datei stimmt mit dem Client überein: nichts unterscheidet sich. + + + Die Datei stimmt in allen Items mit dem Client überein; sie hat keine Formeln zum Vergleichen. + + + Items mit Unterschieden ({0} von {1}) + + + Formeleinträge mit Unterschieden ({0} von {1}) + + + Items: alle {0} sind im Client und in der Datei gleich. + + + Formeleinträge: alle {0} sind im Client und in der Datei gleich. + + + Formeln: nicht verglichen, die Datei hat keine. Die Formeln des Clients bleiben, wie sie sind. + + + Als ein Schritt des Rückgängig-Verlaufs übernommen: {0} Items und {1} Formeleinträge. Was übernommen wurde, unterscheidet sich nicht mehr. + + + Weiter unten noch aufgeführt, ohne Haken: was nicht angehakt war oder nicht übernommen werden kann. + + + Nach dem Übernehmen vergleicht das Fenster neu: Was übernommen wurde, unterscheidet sich nicht mehr und verschwindet aus der Liste. + + + Nichts ist angehakt. + + + Der Pfad im Feld ist nicht die verglichene Datei: neu vergleichen. + + + Das Item wird getragen oder ist in einem Item-Fenster: Es wird nicht hinzugefügt, solange das so ist. + + + Hinzugefügt + + + Im Client + + + Übernehmen + + + Ersetzen + + + Import übernehmen + + + Das ändert die Items und Formeln des Clients mit den angehakten Zeilen aus {0}: {1} Items hinzugefügt, {2} geändert, {3} entfernt; {4} Formeleinträge ändern sich. + + + Alles, was nicht angehakt oder nicht aufgeführt ist, bleibt, wie es ist. Rückgängig nimmt den ganzen Import zurück. + + + Austauschdatei ersetzen + + + {0} bekommt die Items und Formeln des Clients: {1} Items hinzugefügt, {2} geändert, {3} entfernt; {4} Formeleinträge ändern sich. + + + {0} bekommt die Items und Formeln des Clients: {1} Items hinzugefügt, {2} geändert, {3} entfernt; die Formeln kommen hinzu. + + + {0} lässt sich nicht als Item-Austauschdatei lesen. Durch die Items und Formeln des Clients ersetzen? + + + {0} hat schon die Items und Formeln des Clients: nichts zu schreiben. + + + Der Export konnte nicht geschrieben werden: + + + {0} Items und alle Formeln nach {1} exportiert. + + + Zwei Dateien vergleichen + + + Vergleicht zwei Austauschdateien, z. B. einen Client-Export mit einem Server-Export: was sich von Datei A zu Datei B ändert, in jeder Sprache. Nichts wird übernommen; ein Unterschied kann als bekannt markiert werden. + + + Datei A + + + Datei B + + + Wählen… + + + Tauschen + + + Dateien vergleichen + + + Datei A: {0} + + + Datei B: {0} + + + Die Dateien haben dieselben Items und Formeln. + + + Die Dateien haben dieselben Items; die Formeln wurden nicht verglichen, eine Datei hat keine. + + + Von Datei A zu Datei B: {0} hinzugefügte, {1} geänderte, {2} entfernte Items; {3} Formeleinträge + + + Von A nach B + + + Items: alle {0} sind in beiden Dateien gleich. + + + Formeleinträge: alle {0} sind in beiden Dateien gleich. + + + Formeln: nicht verglichen, eine Datei hat keine. + + + Es ist mehrmals in Datei A und wird daher nicht verglichen. + + + Es ist mehrmals in Datei B und wird daher nicht verglichen. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 11e97de596..6a45faa4ec 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2119,4 +2119,241 @@ Click a problem of a monster to select the monster and open the tool of its field. + + Item Sync + + + Import… + + + Export… + + + Exchange file + + + Compare again + + + Import lists how a file differs from the client's items and formulas, only what differs (what is the same on both sides is left out), and takes the checked values into the client. Export writes the client's items and formulas into a file. + + + Import from {0} + + + From OpenMU, server {0} + + + {0} added, {1} changed, {2} removed items; {3} formula entries + + + Check all + + + Check none + + + A field in all items + + + Check them + + + Uncheck them + + + Changed + + + Removed + + + Fields + + + Now + + + From the file + + + Note + + + Entry + + + Known + + + A known difference: meant to differ between the client and the server, so it stays unchecked. Kept in Data/Items/Sync/KnownDifferences.json. + + + OpenMU keeps the drop level in a byte (at most 255) + + + It has no model yet + + + Its profile rules give {0}, the file {1}: a wing tier or a tag may be missing + + + Apply the checked changes + + + Import items + + + The checks refuse the change; the rows of the items with errors are unchecked, and all formula rows when the formulas have one: + + + Fields of {0} + + + Item exchange files + + + The client's data changed since the comparison: compare again. + + + It is in the file more than once: it stays as it is in the client + + + To see differences, change an item in the editor or in the file, or pick another file, then compare again. + + + The file matches the client: nothing differs. + + + The file matches the client in all items; it has no formulas to compare. + + + Items that differ ({0} of {1}) + + + Formula entries that differ ({0} of {1}) + + + Items: all {0} are the same in the client and the file. + + + Formula entries: all {0} are the same in the client and the file. + + + Formulas: not compared, the file has none. The client's formulas stay as they are. + + + Applied as one step of the undo history: {0} items and {1} formula entries. What was applied no longer differs. + + + Still listed below, unchecked: what was not checked or cannot be applied. + + + After applying, the window compares again: what was applied no longer differs and leaves the list. + + + Nothing is checked. + + + The path in the field is not the compared file: compare again. + + + The item is worn or in an item window: it is not added while it is. + + + Added + + + In the client + + + Apply + + + Replace + + + Apply the import + + + This changes the client's items and formulas with the checked rows of {0}: {1} items added, {2} changed, {3} removed; {4} formula entries change. + + + Everything that is not checked or not listed stays as it is. Undo takes back the whole import. + + + Replace the exchange file + + + {0} gets the client's items and formulas: {1} items added, {2} changed, {3} removed; {4} formula entries change. + + + {0} gets the client's items and formulas: {1} items added, {2} changed, {3} removed; the formulas are added. + + + {0} cannot be read as an item exchange file. Replace it with the client's items and formulas? + + + {0} already has the client's items and formulas: nothing to write. + + + The export could not be written: + + + Exported {0} items and all formulas to {1}. + + + Compare two files + + + Compares two exchange files, e.g. a client export with a server export: what changes from file A to file B, in every language. Nothing is applied; a difference can be marked as known. + + + File A + + + File B + + + Pick… + + + Swap + + + Compare the files + + + File A: {0} + + + File B: {0} + + + The files have the same items and formulas. + + + The files have the same items; the formulas were not compared, a file has none. + + + From file A to file B: {0} added, {1} changed, {2} removed items; {3} formula entries + + + From A to B + + + Items: all {0} are the same in both files. + + + Formula entries: all {0} are the same in both files. + + + Formulas: not compared, a file has none. + + + It is in file A more than once, so it is not compared. + + + It is in file B more than once, so it is not compared. + diff --git a/src/MuEditor/Core/MuEditorCore.cpp b/src/MuEditor/Core/MuEditorCore.cpp index 261b175996..9a4b13fa5d 100644 --- a/src/MuEditor/Core/MuEditorCore.cpp +++ b/src/MuEditor/Core/MuEditorCore.cpp @@ -24,6 +24,7 @@ #include "../MuEditor/UI/ItemTools/MuItemCategoriesUI.h" #include "../MuEditor/UI/ItemTools/MuItemChecksUI.h" #include "../MuEditor/UI/ItemTools/MuItemFormulasUI.h" +#include "../MuEditor/UI/ItemTools/MuItemSyncUI.h" #include "../MuEditor/UI/ItemTools/MuItemGlowColorsUI.h" #include "../MuEditor/UI/ItemTools/MuItemModelUI.h" #include "../MuEditor/UI/ItemTools/MuItemRequirementsUI.h" @@ -604,6 +605,10 @@ void CMuEditorCore::RenderItemTools() { g_MuItemFormulasUI.Render(&m_windows.itemFormulas); } + if (m_windows.itemSync) + { + g_MuItemSyncUI.Render(&m_windows.itemSync); + } if (m_windows.itemChecks) { g_MuItemChecksUI.Render(&m_windows.itemChecks); diff --git a/src/MuEditor/README.md b/src/MuEditor/README.md index 491b45c5f5..4442f6946b 100644 --- a/src/MuEditor/README.md +++ b/src/MuEditor/README.md @@ -8,6 +8,7 @@ The MU Editor is a C++ ImGui-based in-game editor that provides real-time game e - **Item Tools**: Browse, add, duplicate, move and remove items and edit their data and models while the game runs, with undo and checks before saving (docs/item-data.md, "Editing items") - **Item Translations**: Item names in every language side by side - **Monster Tools**: Browse, add, copy and remove monster numbers, set their kind, translate their names (with import and export of the old NpcName txt files) and check them while the game runs, with undo (docs/monster-data.md, "Editing monsters") +- **Item Sync**: Import and Export of the items and item formulas with an OpenMU server through a file; an import shows a diff whose checked rows are applied (docs/item-data.md, "Item Sync") ## Key Features - ✅ Completely wrapped in `#ifdef _EDITOR` - **excluded from release builds** @@ -48,6 +49,7 @@ stores it when a field is left. - **MuItemGlowColorsUI** - The glow color list: new colors, changed values and unused colors removed (GlowColorDialog, which the model tool's color fields open too) - **MuItemFormulasUI** - The item stat file: drop level bonus, default, profiles, level bonus tables and profile rules, changed on a copy and applied after the list of the items it reaches (ItemStatTermFields, ItemStatFileChange) - **MuItemChecksUI** - The checks of a save in the shared problem table, each leading to its item and field (ItemCheckTargets) +- **MuItemSyncUI** - Import and Export of the item exchange file with OpenMU: the import's diff of the items and formulas with a box per field (ItemSyncDiff, which also keeps the known differences), applied as one undo step after a confirmation; the export of the client's data, confirmed when it replaces a file (ItemSyncApply); and a read-only comparison of two files - **ItemToolWindow** - What every item tool window shares: its title, the selected item, and the item edit history as the editor bar's undo target ### Item Actions (UI/ItemEditor) @@ -185,6 +187,7 @@ main.exe --editor - **Console** - Dual-panel console with auto-scroll and copy/clear - **Item Tools** - Live editing of items and their models - **Monster Tools** - Live editing of monster names and kinds +- **Item Sync** - Export and Import of the item data with OpenMU - **Input Blocking** - Game input blocked when hovering UI - **No Separate Console** - All output in ImGui, no Windows console window diff --git a/src/MuEditor/UI/Common/MuEditorToolWindows.h b/src/MuEditor/UI/Common/MuEditorToolWindows.h index d8272b7c72..bb0f5a5bba 100644 --- a/src/MuEditor/UI/Common/MuEditorToolWindows.h +++ b/src/MuEditor/UI/Common/MuEditorToolWindows.h @@ -17,6 +17,7 @@ struct ToolWindows bool itemModel = false; bool itemGlowColors = false; bool itemFormulas = false; + bool itemSync = false; bool itemChecks = false; bool itemTranslations = false; bool monsterBrowser = false; diff --git a/src/MuEditor/UI/Common/MuEditorWindowIds.h b/src/MuEditor/UI/Common/MuEditorWindowIds.h index ab7c9fff81..a2dafbba35 100644 --- a/src/MuEditor/UI/Common/MuEditorWindowIds.h +++ b/src/MuEditor/UI/Common/MuEditorWindowIds.h @@ -20,6 +20,7 @@ inline constexpr char ItemCategories[] = "###ItemCategories"; inline constexpr char ItemModel[] = "###ItemModel"; inline constexpr char ItemGlowColors[] = "###ItemGlowColors"; inline constexpr char ItemFormulas[] = "###ItemFormulas"; +inline constexpr char ItemSync[] = "###ItemSync"; inline constexpr char ItemChecks[] = "###ItemChecks"; inline constexpr char SkillEditor[] = "###SkillEditor"; inline constexpr char DevEditor[] = "###DevEditor"; diff --git a/src/MuEditor/UI/Common/ToolGroups.cpp b/src/MuEditor/UI/Common/ToolGroups.cpp index 6d648f9e2d..69a47606c4 100644 --- a/src/MuEditor/UI/Common/ToolGroups.cpp +++ b/src/MuEditor/UI/Common/ToolGroups.cpp @@ -14,7 +14,7 @@ namespace MuEditor { namespace { -const std::array ItemToolEntries = {{ +const std::array ItemToolEntries = {{ {WindowIds::ItemBasics, &I18N::Editor::ItemBasics, &ToolWindows::itemBasics}, {WindowIds::ItemRequirements, &I18N::Editor::ItemRequirements, &ToolWindows::itemRequirements}, {WindowIds::ItemRules, &I18N::Editor::ItemRules, &ToolWindows::itemRules}, @@ -22,6 +22,7 @@ const std::array ItemToolEntries = {{ {WindowIds::ItemModel, &I18N::Editor::ItemModel, &ToolWindows::itemModel}, {WindowIds::ItemGlowColors, &I18N::Editor::GlowColors, &ToolWindows::itemGlowColors}, {WindowIds::ItemFormulas, &I18N::Editor::ItemFormulas, &ToolWindows::itemFormulas}, + {WindowIds::ItemSync, &I18N::Editor::ItemSync, &ToolWindows::itemSync}, {WindowIds::ItemChecks, &I18N::Editor::ItemChecks, &ToolWindows::itemChecks}, {WindowIds::ItemTranslations, &I18N::Editor::ItemTranslations, &ToolWindows::itemTranslations}, }}; diff --git a/src/MuEditor/UI/ItemEditor/ItemEditorActions.cpp b/src/MuEditor/UI/ItemEditor/ItemEditorActions.cpp index 2cd4db0c5d..dfa372f9f1 100644 --- a/src/MuEditor/UI/ItemEditor/ItemEditorActions.cpp +++ b/src/MuEditor/UI/ItemEditor/ItemEditorActions.cpp @@ -205,10 +205,15 @@ void CItemEditorActions::RenderExportS6E3Button() swprintf_s(fileName, _countof(fileName), L"Data\\Local\\%ls\\Item_S6E3.bmd", g_strSelectedML.c_str()); std::string problems; - if (g_ItemDataHandler.ExportAsS6E3(fileName, problems)) + std::string notes; + if (g_ItemDataHandler.ExportAsS6E3(fileName, problems, notes)) { std::string filename_str = "Item_" + std::string(g_strSelectedML.begin(), g_strSelectedML.end()) + "_S6E3.bmd"; + if (!notes.empty()) + { + g_MuEditorConsoleUI.LogEditor(notes); + } g_MuEditorConsoleUI.LogEditor("Exported items as S6E3 legacy format: " + filename_str); ImGui::OpenPopup("Export S6E3 Success"); } diff --git a/src/MuEditor/UI/ItemTools/ItemEdits.cpp b/src/MuEditor/UI/ItemTools/ItemEdits.cpp index 9e67613aee..ca641676b1 100644 --- a/src/MuEditor/UI/ItemTools/ItemEdits.cpp +++ b/src/MuEditor/UI/ItemTools/ItemEdits.cpp @@ -162,7 +162,8 @@ bool ItemEdits::SetItem(std::string label, const ItemDefinition& item) return SetItems(std::move(label), std::span(&item, 1)); } -bool ItemEdits::SetItems(std::string label, std::span items) +bool ItemEdits::SetItems(std::string label, std::span items, + Data::Items::StatProfileStore profiles) { std::vector itemTypes; for (const ItemDefinition& item : items) @@ -184,7 +185,7 @@ bool ItemEdits::SetItems(std::string label, std::span item itemTypes.push_back(itemType); } } - RecordSlots(std::move(label), itemTypes, [&] { g_ItemDataHandler.SetItems(items); }); + RecordSlots(std::move(label), itemTypes, [&] { g_ItemDataHandler.SetItems(items, profiles); }); return true; } diff --git a/src/MuEditor/UI/ItemTools/ItemEdits.h b/src/MuEditor/UI/ItemTools/ItemEdits.h index 759db9bb21..8ece888ef8 100644 --- a/src/MuEditor/UI/ItemTools/ItemEdits.h +++ b/src/MuEditor/UI/ItemTools/ItemEdits.h @@ -4,6 +4,8 @@ #include "ItemEditHistory.h" +#include "Data/GameData/ItemData/ItemAttributeConversion.h" + #include #include #include @@ -23,7 +25,9 @@ class ItemEdits // False, with nothing changed, when it would change the size or slot of an // item the game holds (IsHeldByCharacter), as its undo would be refused. bool SetItem(std::string label, const Data::Items::ItemDefinition& item); - bool SetItems(std::string label, std::span items); + // AsGiven keeps the stat profile each item names as a pick (CItemDataHandler::SetItems). + bool SetItems(std::string label, std::span items, + Data::Items::StatProfileStore profiles = Data::Items::StatProfileStore::Follow); // CItemDataHandler::SetItemName; false when it refused the name. bool SetItemName(std::string label, int itemType, std::string_view locale, std::string text); bool AddItem(std::string label, const Data::Items::ItemDefinition& item, diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp new file mode 100644 index 0000000000..549d9dfb45 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -0,0 +1,334 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "ItemSyncApply.h" + +#include "ItemEditHistory.h" +#include "ItemEdits.h" +#include "ItemHeldByCharacter.h" +#include "ItemStatFileChange.h" + +#include "Data/DataHandler/ItemData/ItemDataHandler.h" +#include "Data/DataHandler/ItemData/ItemJsonStorage.h" +#include "Data/GameData/ItemData/ItemAttributeConversion.h" +#include "Data/GameData/ItemData/ItemDataValidation.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" +#include "Data/GameData/ItemData/ItemModelDatabase.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace MuEditor::Items +{ +namespace +{ +using Data::Items::ItemDataIssue; +using Data::Items::ItemDefinition; +using Data::Items::ItemExchangeFile; + +// The rule all shipped items follow: groups 0 to 5, a hand slot, two cells wide or more. +bool IsTwoHanded(const ItemDefinition& item) +{ + const bool handSlot = item.slot == Data::Items::ItemSlot::MainHand || item.slot == Data::Items::ItemSlot::OffHand; + return item.group <= 5 && handSlot && item.width >= 2; +} + +// "" for the default, as the profile rules give it. +std::string RuleName(const std::string& profile) +{ + return profile == Data::Items::StatProfileNames::Default ? std::string() : profile; +} + +} // namespace + +ItemExchangeFile BuildClientExchange() +{ + return Data::Items::BuildItemExchange(g_ItemDatabase.GetAllSlots(), g_ItemDataHandler.GetStatFile()); +} + +ItemDefinition ToClientItem(const ItemDefinition* current, const ItemDefinition& exchange, + const Data::Items::ItemStatProfiles& profiles) +{ + ItemDefinition item = current != nullptr ? *current : ItemDefinition{}; + item.group = exchange.group; + item.number = exchange.number; + // The shared fields; the client-only ones stay. + Data::Items::CopyExchangeFields(exchange, item); + if (current == nullptr) + { + item.twoHanded = IsTwoHanded(item); + item.weaponKind = Data::Items::GetGroupWeaponKind(item); + } + // "" stores the item by its rules (CItemDataHandler::SetItems). + const std::string ruled = Data::Items::DeriveStatProfile(profiles, item); + item.statProfile = RuleName(exchange.statProfile) == ruled ? std::string() : exchange.statProfile; + return item; +} + +std::vector DescribeNewItem(const ItemDefinition& exchange) +{ + std::vector notes; + const int itemType = Data::Items::MakeItemType(exchange.group, exchange.number); + if (g_ItemModelDatabase.Find(itemType) == nullptr) + { + notes.push_back(I18N::Editor::ItHasNoModelYet); + } + const ItemDefinition item = ToClientItem(nullptr, exchange, g_ItemDatabase.GetStatProfiles()); + if (!item.statProfile.empty()) + { + const std::string ruled = Data::Items::DeriveStatProfile(g_ItemDatabase.GetStatProfiles(), item); + notes.push_back(I18N::Format( + I18N::Editor::ItsProfileRulesGive0TheFile1, + {ruled.empty() ? std::string(Data::Items::StatProfileNames::Default) : ruled, exchange.statProfile})); + } + return notes; +} + +namespace +{ +using Data::Items::ItemStatFile; +using Data::Items::ItemStatProfiles; + +// What an import changes in the client's items: the items it stores and the ones it removes. +struct ImportChange +{ + std::vector items; + std::vector removed; +}; + +// The formulas the import stores, with the client's profile rules; nothing when they stay. +std::optional FindChangedStatFile(const ItemExchangeFile& applied, const ItemExchangeFile& client) +{ + if (!applied.formulas || !client.formulas || *applied.formulas == *client.formulas) + { + return std::nullopt; + } + ItemStatFile statFile = *applied.formulas; + statFile.profileRules = g_ItemDataHandler.GetStatFile().profileRules; + return statFile; +} + +// The items the checked rows change (from `applied`, the client's data with them taken) and the +// ones they remove. +ImportChange CollectImportChange(const ItemSyncDiff& diff, const ItemExchangeFile& applied, + const ItemStatProfiles& profiles) +{ + ImportChange change; + const Data::Items::ExchangeItemIndex appliedItems = Data::Items::IndexExchangeItems(applied); + for (const SyncItem& row : diff.items) + { + const int itemType = Data::Items::MakeItemType(row.group, row.number); + if (row.change == SyncChange::Removed) + { + if (row.checked && row.locked.empty()) + { + change.removed.push_back(itemType); + } + continue; + } + const auto found = appliedItems.find({row.group, row.number}); + const ItemDefinition* exchange = found != appliedItems.end() ? found->second : nullptr; + const ItemDefinition* current = g_ItemDatabase.Find(itemType); + if (exchange == nullptr || (current != nullptr && Data::Items::ToExchangeItem(*current) == *exchange)) + { + continue; + } + change.items.push_back(ToClientItem(current, *exchange, profiles)); + } + return change; +} + +// What the game holds keeps its size and slot and stays (ItemEdits refuses the whole change +// otherwise); the character may have taken the item since the comparison. +void CheckHeldItems(const ImportChange& change, std::vector& issues) +{ + for (const ItemDefinition& item : change.items) + { + // The slot, also an empty one: an item the client has no data for can be held too. + const int itemType = Data::Items::MakeItemType(item.group, item.number); + const ItemDefinition& slot = g_ItemDatabase.GetAllSlots()[static_cast(itemType)]; + if (!KeepsShape(slot, item) && IsHeldByCharacter(itemType)) + { + issues.push_back({Data::Items::ItemDataIssueSeverity::Error, "", item.group, item.number, "", + slot.Exists() + ? "is worn or in an item window now, so its size and slot stay: compare again" + : "is worn or in an item window now, so it is not added: compare again"}); + } + } + for (const int itemType : change.removed) + { + if (IsHeldByCharacter(itemType)) + { + issues.push_back({Data::Items::ItemDataIssueSeverity::Error, "", Data::Items::GetItemGroup(itemType), + Data::Items::GetItemNumber(itemType), "", + "is worn or in an item window now, so it is not removed: compare again"}); + } + } +} + +// The checks of a save, on the data as it would be; only their errors go into `issues`. +void CheckImportedData(const ImportChange& change, const ItemStatProfiles& profiles, + const std::optional& statFile, std::vector& issues) +{ + std::map candidates; + for (const ItemDefinition& item : g_ItemDatabase.GetAllSlots()) + { + if (item.Exists()) + { + candidates[Data::Items::MakeItemType(item.group, item.number)] = item; + } + } + for (const ItemDefinition& item : change.items) + { + ItemDefinition candidate = item; + if (candidate.statProfile.empty()) + { + candidate.statProfile = Data::Items::DeriveStatProfile(profiles, candidate); + } + candidates[Data::Items::MakeItemType(item.group, item.number)] = std::move(candidate); + } + for (const int itemType : change.removed) + { + candidates.erase(itemType); + } + std::vector checked; + for (auto& [itemType, item] : candidates) + { + checked.push_back(std::move(item)); + } + std::vector checkIssues; + Data::Items::ValidateItems(checked, checkIssues); + Data::Items::ValidateStatProfiles(checked, profiles, Data::Items::GetItemStatsFile().generic_string(), checkIssues); + if (statFile) + { + Data::Items::ValidateItemStatsAsWritten(*statFile, checkIssues); + } + std::ranges::copy_if(checkIssues, std::back_inserter(issues), [](const ItemDataIssue& issue) + { return issue.severity == Data::Items::ItemDataIssueSeverity::Error; }); +} + +// The change as one step of the undo history: the item stat file first, so the items' profiles +// follow the new rules, then the items. +void RecordImport(const ImportChange& change, const ItemStatProfiles& profiles, + const std::optional& statFile) +{ + const std::string label = I18N::Editor::ImportItems; + g_ItemEdits.BeginGroup(label); + if (statFile) + { + const std::vector changes = + FindStatProfileChanges(g_ItemDatabase.GetAllSlots(), g_ItemDatabase.GetStatProfiles(), profiles); + g_ItemEdits.SetStatFile(label, *statFile, ApplyStatProfileChanges(g_ItemDatabase.GetAllSlots(), changes)); + } + if (!change.items.empty()) + { + // ToClientItem decided each item's profile: "" follows the rules, another one is a pick. + g_ItemEdits.SetItems(label, change.items, Data::Items::StatProfileStore::AsGiven); + } + for (const int itemType : change.removed) + { + g_ItemEdits.RemoveItem(label, itemType); + } + g_ItemEdits.EndGroup(); +} +} // namespace + +bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, std::vector& issues) +{ + const ItemExchangeFile client = BuildClientExchange(); + std::vector applyIssues; + const ItemExchangeFile applied = ApplyItemSyncDiff(client, file, diff, applyIssues); + issues.insert(issues.end(), applyIssues.begin(), applyIssues.end()); + if (Data::Items::HasErrors(applyIssues)) + { + return false; + } + const std::optional statFile = FindChangedStatFile(applied, client); + const ItemStatProfiles profiles = statFile ? ItemStatProfiles(*statFile) : g_ItemDatabase.GetStatProfiles(); + const ImportChange change = CollectImportChange(diff, applied, profiles); + + std::vector refusals; + CheckHeldItems(change, refusals); + if (refusals.empty()) + { + CheckImportedData(change, profiles, statFile, refusals); + } + if (!refusals.empty()) + { + issues.insert(issues.end(), refusals.begin(), refusals.end()); + return false; + } + RecordImport(change, profiles, statFile); + return true; +} + +ItemSyncExportPreview PreviewItemSyncExport(const std::filesystem::path& path) +{ + ItemSyncExportPreview preview; + std::error_code error; + preview.fileExists = std::filesystem::exists(path, error); + const std::optional text = preview.fileExists ? Data::Items::Json::ReadTextFile(path) : std::nullopt; + if (!text) + { + return preview; + } + ItemExchangeFile file; + std::vector issues; + Data::Items::ReadItemExchangeJson(*text, path.generic_string(), file, issues); + if (!Data::Items::CanImportItemExchange(file, issues)) + { + return preview; + } + preview.readable = true; + preview.fileHasFormulas = file.formulas.has_value(); + + const ItemExchangeFile client = BuildClientExchange(); + const Data::Items::ExchangeItemIndex fileItems = Data::Items::IndexExchangeItems(file); + // An item the file has twice is not among its items, but the file has it. + std::set> fileKeys(file.repeatedItems.begin(), file.repeatedItems.end()); + for (const auto& [key, item] : fileItems) + { + fileKeys.insert(key); + } + for (const ItemDefinition& item : client.items) + { + const std::pair key{item.group, item.number}; + if (!fileKeys.contains(key)) + { + ++preview.added; + continue; + } + const auto found = fileItems.find(key); + if (found == fileItems.end() || !(*found->second == item)) + { + ++preview.changed; + } + fileKeys.erase(key); + } + preview.removed = static_cast(fileKeys.size()); + if (file.formulas && client.formulas) + { + preview.formulaEntries = CountFormulaDifferences(*file.formulas, *client.formulas); + } + return preview; +} + +bool WriteItemSyncExport(const std::filesystem::path& path, size_t& writtenItems, std::vector& issues) +{ + const ItemExchangeFile client = BuildClientExchange(); + writtenItems = client.items.size(); + return Data::Items::WriteItemTextFile(path, Data::Items::WriteItemExchangeJson(client), issues) == + Data::Items::ItemDataSaveResult::Saved; +} +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.h b/src/MuEditor/UI/ItemTools/ItemSyncApply.h new file mode 100644 index 0000000000..faf41b7e42 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.h @@ -0,0 +1,72 @@ +#pragma once + +#ifdef _EDITOR + +#include "ItemSyncDiff.h" + +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemDefinition.h" +#include "Data/GameData/ItemData/ItemExchangeFile.h" +#include "Data/GameData/ItemData/ItemStatProfiles.h" + +#include +#include +#include + +// What the Item Sync window does (phase 7): an import changes the client's data with the checked +// rows of a diff as one step of the undo history; an export writes the client's items and +// formulas into a file, without a diff (OpenMU's import page shows one). +namespace MuEditor::Items +{ +// The client's items and item formulas as an exchange file. +Data::Items::ItemExchangeFile BuildClientExchange(); + +// The client's item `current` (nullptr for a new one) with the shared fields of `exchange`. +// Its client-only fields stay; a new item gets the two-handed flag and the weapon kind its +// group gives. The item follows its profile rules (`profiles`) when the file's profile is the +// one they give; another one is a pick. +Data::Items::ItemDefinition ToClientItem(const Data::Items::ItemDefinition* current, + const Data::Items::ItemDefinition& exchange, + const Data::Items::ItemStatProfiles& profiles); + +// What a new item of the file lacks for the client: a model entry, or what its profile rules +// need and the file cannot give (a wing tier, a tag). +std::vector DescribeNewItem(const Data::Items::ItemDefinition& exchange); + +// Applies the checked rows of an import of `file` to the client's data as one step of the undo +// history: the item formulas first, so the items follow the new profile rules, then the +// items. The data as it would be is checked first, with the checks of a save; with errors +// nothing changes, false is returned and the errors are in `issues`. +bool ApplyItemSyncImport(const ItemSyncDiff& diff, const Data::Items::ItemExchangeFile& file, + std::vector& issues); + +// What an export would change in the file at `path`, for the confirmation before it replaces +// the file. The items are compared as a whole (a name in a language only the file has counts +// too, as the file loses it), the formulas by entry. +struct ItemSyncExportPreview +{ + bool fileExists = false; + // An exchange file the reader takes (an item it has twice is no obstacle: it is replaced). + bool readable = false; + int added = 0; + int changed = 0; + int removed = 0; + bool fileHasFormulas = false; + // The formula entries that change, while the file has formulas. + int formulaEntries = 0; + + // A readable file that already has the client's items and formulas. + bool ChangesNothing() const + { + return readable && added == 0 && changed == 0 && removed == 0 && fileHasFormulas && formulaEntries == 0; + } +}; +ItemSyncExportPreview PreviewItemSyncExport(const std::filesystem::path& path); + +// Writes the client's items and formulas into `path` (source MuMain), replacing what it holds; +// `writtenItems` is how many items it has. +bool WriteItemSyncExport(const std::filesystem::path& path, size_t& writtenItems, + std::vector& issues); +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp new file mode 100644 index 0000000000..253b17726a --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -0,0 +1,664 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "ItemSyncDiff.h" + +#include "Data/DataHandler/ItemData/ItemJsonStorage.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" +#include "Data/GameData/ItemData/ItemJsonFormat.h" +#include "Data/GameData/ItemData/ItemStatsJson.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" + +#include +#include +#include + +namespace MuEditor::Items +{ +namespace +{ +using Data::Items::ItemDataIssue; +using Data::Items::ItemDefinition; +using Data::Items::ItemExchangeFile; +using Data::Items::Json::OrderedJson; + +// A side's fields by their path, as JSON. +using Fields = std::map>; +using ItemKey = std::pair; + +constexpr const char* NameField = "name"; +constexpr const char* TagsField = "tags"; +constexpr const char* LevelField = "level"; +// A drop level OpenMU can keep: its field is a byte. +constexpr int OpenMUMaxDropLevel = 255; +// What the game holds keeps (IsHeldByCharacter). +constexpr const char* HeldFields[] = {"width", "height", "slot"}; + +std::string ToText(const OrderedJson& value) +{ + return value.dump(-1, ' ', false, OrderedJson::error_handler_t::replace); +} + +std::optional TextOf(const Fields& fields, const std::string& path) +{ + const auto found = fields.find(path); + return found != fields.end() ? std::optional(ToText(found->second)) : std::nullopt; +} + +// ---------------------------------------------------------------- items + +Fields FlattenItem(const ItemDefinition& item) +{ + Fields fields; + const OrderedJson json = Data::Items::WriteItemJson(item); + for (const auto& [key, value] : json.items()) + { + if (key == Data::Items::Json::Keys::Number) + { + continue; + } + if (key == TagsField) + { + for (const OrderedJson& tag : value) + { + fields[std::string(TagsField) + "." + tag.get()] = true; + } + continue; + } + if (value.is_object()) + { + for (const auto& [part, partValue] : value.items()) + { + fields[key + "." + part] = partValue; + } + continue; + } + fields[key] = value; + } + return fields; +} + +ItemDefinition UnflattenItem(int group, int number, const Fields& fields, std::vector& issues) +{ + OrderedJson json; + json[Data::Items::Json::Keys::Number] = number; + for (const auto& [path, value] : fields) + { + const size_t dot = path.find('.'); + if (dot == std::string::npos) + { + json[path] = value; + continue; + } + const std::string top = path.substr(0, dot); + const std::string part = path.substr(dot + 1); + if (top == TagsField) + { + json[top].push_back(part); + } + else + { + json[top][part] = value; + } + } + ItemDefinition item; + Data::Items::ReadItemJson(json, "", group, item, issues); + return Data::Items::ToExchangeItem(item); +} + +bool IsNamePath(const std::string& path, std::string& locale) +{ + const std::string prefix = std::string(NameField) + "."; + if (path.rfind(prefix, 0) != 0) + { + return false; + } + locale = path.substr(prefix.size()); + return true; +} + +// Whether `path` of the incoming side takes part: a language the incoming side lacks stays, +// and an import takes names only in the client's UI languages. A comparison of two files +// (comparingFiles) takes every one. +bool TakesPart(const std::string& path, bool incomingHasIt, const SyncOptions& options) +{ + std::string locale; + if (!IsNamePath(path, locale) || options.comparingFiles) + { + return true; + } + if (!incomingHasIt) + { + return false; + } + return std::ranges::find(options.uiLocales, locale) != options.uiLocales.end(); +} + +bool IsHeld(const SyncOptions& options, const ItemKey& key) +{ + return options.isHeld && options.isHeld(Data::Items::MakeItemType(key.first, key.second)); +} + +void MarkField(SyncField& field, const ItemKey& key, const ItemExchangeFile& incoming, const Fields& current, + const Fields& incomingFields, const SyncOptions& options) +{ + field.known = options.known != nullptr && options.known->Contains(key.first, key.second, field.field); + if (IsHeld(options, key) && std::ranges::find(HeldFields, field.field) != std::end(HeldFields)) + { + field.locked = I18N::Editor::TheItemIsWornOrInAnItemWindowItsSizeAndSlotStayAsTheyAre; + } + if (field.field == LevelField && incoming.source == Data::Items::ItemExchangeSourceOpenMU) + { + const auto client = current.find(LevelField); + const auto server = incomingFields.find(LevelField); + if (client != current.end() && client->second.get() > OpenMUMaxDropLevel && + server != incomingFields.end() && server->second.get() == OpenMUMaxDropLevel) + { + field.inexact = I18N::Editor::OpenMUKeepsTheDropLevelInAByteAtMost255; + } + } + field.checked = !field.known && field.locked.empty() && field.inexact.empty(); +} + +SyncItem CompareItem(const ItemKey& key, const ItemDefinition* current, const ItemDefinition* incoming, + const ItemExchangeFile& incomingFile, const SyncOptions& options) +{ + SyncItem row; + row.group = key.first; + row.number = key.second; + row.name = (incoming != nullptr ? incoming : current)->names.GetNeutral(); + row.known = options.known != nullptr && options.known->Contains(key.first, key.second, ""); + const Fields currentFields = current != nullptr ? FlattenItem(*current) : Fields{}; + const Fields incomingFields = incoming != nullptr ? FlattenItem(*incoming) : Fields{}; + + if (current == nullptr) + { + row.change = SyncChange::New; + for (const auto& [path, value] : incomingFields) + { + if (TakesPart(path, true, options)) + { + row.fields.push_back({path, std::nullopt, ToText(value)}); + } + } + // An item of a type the client has no data for yet can be held too (the server sent + // it): its size would change under the item grid (ItemEdits refuses it). + if (IsHeld(options, key)) + { + row.locked = I18N::Editor::TheItemIsWornOrInAnItemWindowItIsNotAdded; + } + row.checked = !row.known && row.locked.empty(); + return row; + } + if (incoming == nullptr) + { + row.change = SyncChange::Removed; + // A comparison of two files shows what file A has (nothing applies them). + if (options.comparingFiles) + { + for (const auto& [path, value] : currentFields) + { + row.fields.push_back({path, ToText(value), std::nullopt}); + } + } + if (IsHeld(options, key)) + { + row.locked = I18N::Editor::TheItemIsWornOrInAnItemWindowItIsNotMovedOrRemoved; + } + return row; + } + + row.change = SyncChange::Changed; + std::set> paths; + for (const auto& [path, value] : currentFields) + { + paths.insert(path); + } + for (const auto& [path, value] : incomingFields) + { + paths.insert(path); + } + for (const std::string& path : paths) + { + const std::optional was = TextOf(currentFields, path); + const std::optional becomes = TextOf(incomingFields, path); + if (was == becomes || !TakesPart(path, becomes.has_value(), options)) + { + continue; + } + SyncField field{path, was, becomes}; + MarkField(field, key, incomingFile, currentFields, incomingFields, options); + row.fields.push_back(std::move(field)); + } + return row; +} + +// ---------------------------------------------------------------- formulas + +constexpr const char* DropLevelBonusEntry = "dropLevelBonus"; +constexpr const char* TablesEntry = "tables"; +constexpr const char* DefaultEntry = "default"; +constexpr const char* ProfilesEntry = "profiles"; +constexpr const char* RequirementsKey = "requirements"; + +Fields FlattenFormulas(const Data::Items::ItemStatFile& file) +{ + Fields fields; + const OrderedJson json = Data::Items::WriteItemStatFormulasJson(file); + for (const auto& [key, value] : json.at(DropLevelBonusEntry).items()) + { + fields[std::string(DropLevelBonusEntry) + "." + key] = value; + } + for (const OrderedJson& table : json.at(TablesEntry)) + { + OrderedJson values = table; + values.erase(NameField); + fields[std::string(TablesEntry) + "." + table.at(NameField).get()] = std::move(values); + } + for (const auto& [stat, section] : json.at(DefaultEntry).items()) + { + for (const auto& [term, value] : section.items()) + { + fields[std::string(DefaultEntry) + "." + stat + "." + term] = value; + } + } + for (const OrderedJson& profile : json.at(ProfilesEntry)) + { + const std::string prefix = std::string(ProfilesEntry) + "." + profile.at(NameField).get(); + fields[prefix] = true; + for (const auto& [stat, section] : profile.items()) + { + if (stat == NameField) + { + continue; + } + for (const auto& [term, value] : section.items()) + { + fields[prefix + "." + stat + "." + term] = value; + } + } + } + return fields; +} + +std::vector SplitPath(const std::string& path) +{ + std::vector parts; + size_t start = 0; + while (true) + { + const size_t dot = path.find('.', start); + parts.push_back(path.substr(start, dot - start)); + if (dot == std::string::npos) + { + return parts; + } + start = dot + 1; + } +} + +std::optional UnflattenFormulas(const Fields& fields, std::vector& issues) +{ + OrderedJson json; + std::map> tables; + std::map> profiles; + for (const auto& [path, value] : fields) + { + const std::vector parts = SplitPath(path); + if (parts[0] == DropLevelBonusEntry && parts.size() == 2) + { + json[DropLevelBonusEntry][parts[1]] = value; + } + else if (parts[0] == TablesEntry && parts.size() == 2) + { + OrderedJson table; + table[NameField] = parts[1]; + for (const auto& [key, entry] : value.items()) + { + table[key] = entry; + } + tables[parts[1]] = std::move(table); + } + else if (parts[0] == DefaultEntry && parts.size() == 3) + { + json[DefaultEntry][parts[1]][parts[2]] = value; + } + else if (parts[0] == ProfilesEntry && (parts.size() == 2 || parts.size() == 4)) + { + OrderedJson& profile = profiles[parts[1]]; + profile[NameField] = parts[1]; + if (parts.size() == 4) + { + profile[parts[2]][parts[3]] = value; + } + } + } + json[TablesEntry] = OrderedJson::array(); + for (auto& [name, table] : tables) + { + json[TablesEntry].push_back(std::move(table)); + } + json[ProfilesEntry] = OrderedJson::array(); + for (auto& [name, profile] : profiles) + { + json[ProfilesEntry].push_back(std::move(profile)); + } + + std::vector readIssues; + Data::Items::ItemStatFile file; + Data::Items::ReadItemStatFormulasJson(json, "formulas", file, readIssues); + const bool valid = !Data::Items::HasErrors(readIssues); + issues.insert(issues.end(), readIssues.begin(), readIssues.end()); + return valid ? std::optional(std::move(file)) : std::nullopt; +} + +// The profile a path belongs to ("profiles.weapon" for "profiles.weapon.damage.table"). +std::optional ProfileOf(const std::string& path) +{ + const std::vector parts = SplitPath(path); + if (parts.size() < 2 || parts[0] != ProfilesEntry) + { + return std::nullopt; + } + return parts[0] + "." + parts[1]; +} + +std::string PartsOf(const Fields& fields, const std::string& profile) +{ + OrderedJson json = OrderedJson::object(); + for (const auto& [path, value] : fields) + { + if (path.rfind(profile + ".", 0) == 0) + { + json[path.substr(profile.size() + 1)] = value; + } + } + return ToText(json); +} + +void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items::ItemStatFile& incoming, + const SyncOptions& options, ItemSyncDiff& diff) +{ + const Fields currentFields = FlattenFormulas(current); + const Fields incomingFields = FlattenFormulas(incoming); + std::set> paths; + for (const auto& [path, value] : currentFields) + { + paths.insert(path); + } + for (const auto& [path, value] : incomingFields) + { + paths.insert(path); + } + // A profile one side lacks is one row as a whole. + std::set> wholeProfiles; + for (const std::string& path : paths) + { + const std::optional profile = ProfileOf(path); + if (profile && *profile == path && currentFields.contains(path) != incomingFields.contains(path)) + { + wholeProfiles.insert(path); + } + } + for (const std::string& path : paths) + { + const std::optional profile = ProfileOf(path); + SyncFormula row; + if (profile && wholeProfiles.contains(*profile)) + { + if (path != *profile) + { + continue; + } + ++diff.comparedFormulaEntries; + row.entry = path; + if (currentFields.contains(path)) + { + row.current = PartsOf(currentFields, path); + } + if (incomingFields.contains(path)) + { + row.incoming = PartsOf(incomingFields, path); + } + } + else + { + // The entry of a profile both sides have only says that it is there. + const bool isEntry = !profile || *profile != path; + row.entry = path; + row.current = TextOf(currentFields, path); + row.incoming = TextOf(incomingFields, path); + diff.comparedFormulaEntries += isEntry ? 1 : 0; + if (row.current == row.incoming) + { + diff.sameFormulaEntries += isEntry ? 1 : 0; + continue; + } + } + row.known = options.known != nullptr && options.known->Contains(row.entry); + // An entry the file lacks starts unchecked, as a removed item does: taking it away would + // leave the items that name a profile or table without it. + row.checked = !row.known && row.incoming.has_value(); + diff.formulas.push_back(std::move(row)); + } +} + +void ApplyFormulas(const ItemExchangeFile& source, ItemExchangeFile& target, const ItemSyncDiff& diff, + std::vector& issues) +{ + if (!source.formulas || !target.formulas || + std::ranges::none_of(diff.formulas, [](const SyncFormula& row) { return row.checked; })) + { + return; + } + const Fields from = FlattenFormulas(*source.formulas); + Fields fields = FlattenFormulas(*target.formulas); + const auto take = [&](const std::string& path) + { + if (const auto found = from.find(path); found != from.end()) + { + fields[path] = found->second; + } + else + { + fields.erase(path); + } + }; + for (const SyncFormula& row : diff.formulas) + { + if (!row.checked) + { + continue; + } + const std::optional profile = ProfileOf(row.entry); + if (profile && *profile == row.entry) + { + // A profile as a whole: its marker and every term. + std::erase_if(fields, [&](const auto& entry) { return entry.first.rfind(row.entry + ".", 0) == 0; }); + for (const auto& [path, value] : from) + { + if (path.rfind(row.entry + ".", 0) == 0) + { + fields[path] = value; + } + } + } + take(row.entry); + } + if (std::optional formulas = UnflattenFormulas(fields, issues)) + { + target.formulas = std::move(*formulas); + } +} +} // namespace + +ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, const SyncOptions& options) +{ + ItemSyncDiff diff; + const ItemExchangeFile& current = client; + const ItemExchangeFile& incoming = file; + const Data::Items::ExchangeItemIndex currentItems = Data::Items::IndexExchangeItems(current); + const Data::Items::ExchangeItemIndex incomingItems = Data::Items::IndexExchangeItems(incoming); + // An item a side has more than once (a file read with repeatedItems; the client's data never + // has one): an error row that is not applied. + const std::set currentRepeated(client.repeatedItems.begin(), client.repeatedItems.end()); + const std::set incomingRepeated(file.repeatedItems.begin(), file.repeatedItems.end()); + std::set repeated = currentRepeated; + repeated.insert(incomingRepeated.begin(), incomingRepeated.end()); + std::set keys = repeated; + for (const auto& [key, item] : currentItems) + { + keys.insert(key); + } + for (const auto& [key, item] : incomingItems) + { + keys.insert(key); + } + diff.comparedItems = static_cast(keys.size()); + for (const ItemKey& key : keys) + { + const auto was = currentItems.find(key); + const auto becomes = incomingItems.find(key); + if (repeated.contains(key)) + { + // A side that has the item twice has it too, though not among its items. + const bool inCurrent = was != currentItems.end() || currentRepeated.contains(key); + const bool inIncoming = becomes != incomingItems.end() || incomingRepeated.contains(key); + SyncItem row; + row.group = key.first; + row.number = key.second; + row.change = !inCurrent ? SyncChange::New : !inIncoming ? SyncChange::Removed : SyncChange::Changed; + if (becomes != incomingItems.end()) + { + row.name = becomes->second->names.GetNeutral(); + } + else if (was != currentItems.end()) + { + row.name = was->second->names.GetNeutral(); + } + if (!options.comparingFiles) + { + row.locked = I18N::Editor::ItIsInTheFileMoreThanOnce; + } + else + { + row.locked = currentRepeated.contains(key) ? I18N::Editor::ItIsInFileAMoreThanOnce + : I18N::Editor::ItIsInFileBMoreThanOnce; + } + diff.items.push_back(std::move(row)); + continue; + } + SyncItem row = CompareItem(key, was != currentItems.end() ? was->second : nullptr, + becomes != incomingItems.end() ? becomes->second : nullptr, incoming, options); + if (row.change != SyncChange::Changed || !row.fields.empty()) + { + diff.items.push_back(std::move(row)); + } + else + { + ++diff.sameItems; + } + } + if (current.formulas && incoming.formulas) + { + CompareFormulas(*current.formulas, *incoming.formulas, options, diff); + } + return diff; +} + +int CountFormulaDifferences(const Data::Items::ItemStatFile& current, const Data::Items::ItemStatFile& incoming) +{ + ItemSyncDiff diff; + CompareFormulas(current, incoming, SyncOptions{}, diff); + return static_cast(diff.formulas.size()); +} + +ItemExchangeFile ApplyItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, + const ItemSyncDiff& diff, std::vector& issues) +{ + ItemExchangeFile target = client; + const ItemExchangeFile& source = file; + const Data::Items::ExchangeItemIndex sourceItems = Data::Items::IndexExchangeItems(source); + std::map targetItems; + for (const ItemDefinition& item : target.items) + { + targetItems[{item.group, item.number}] = item; + } + for (const SyncItem& row : diff.items) + { + const ItemKey key{row.group, row.number}; + const auto from = sourceItems.find(key); + if (row.change == SyncChange::Removed) + { + if (row.checked && row.locked.empty()) + { + targetItems.erase(key); + } + continue; + } + if (from == sourceItems.end()) + { + continue; + } + const Fields sourceFields = FlattenItem(*from->second); + if (row.change == SyncChange::New) + { + if (!row.checked || !row.locked.empty()) + { + continue; + } + Fields fields; + for (const SyncField& field : row.fields) + { + // A field the source no longer has (its data changed since the comparison) keeps + // its default. + if (const auto value = sourceFields.find(field.field); value != sourceFields.end()) + { + fields[field.field] = value->second; + } + } + targetItems[key] = UnflattenItem(row.group, row.number, fields, issues); + continue; + } + // An item the client no longer has (its data changed since the diff) is left out. + const auto target = targetItems.find(key); + if (target == targetItems.end()) + { + continue; + } + Fields fields = FlattenItem(target->second); + bool changed = false; + for (const SyncField& field : row.fields) + { + if (!field.checked || !field.locked.empty()) + { + continue; + } + if (const auto value = sourceFields.find(field.field); value != sourceFields.end()) + { + fields[field.field] = value->second; + } + else + { + fields.erase(field.field); + } + changed = true; + } + if (changed) + { + targetItems[key] = UnflattenItem(row.group, row.number, fields, issues); + } + } + target.items.clear(); + for (auto& [key, item] : targetItems) + { + target.items.push_back(std::move(item)); + } + ApplyFormulas(source, target, diff, issues); + return target; +} +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h new file mode 100644 index 0000000000..6d74614973 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -0,0 +1,124 @@ +#pragma once + +#ifdef _EDITOR + +#include "KnownDifferences.h" + +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemExchangeFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The diff of the Item Sync window's import (phase 7): the client's items and item formulas +// against an item exchange file, field by field, and the client's data with the checked rows +// taken from the file. The fields are named by their keys in the item files ("width", +// "requirements.strength", "name.de", "tags.ammunition") and in the item stat file +// ("dropLevelBonus.excellent", "tables.damageIncreaseWeapons", "default.damage.excellent", +// "profiles.weapon.requirements.strength"; "profiles.weapon" for a profile as a whole). An +// export writes the client's data without a diff (ItemSyncApply.h). +namespace MuEditor::Items +{ +struct SyncField +{ + std::string field; + // As JSON; nothing when that side has the field's default (it leaves the field out). + std::optional current; + std::optional incoming; + bool known = false; + // Why it cannot be checked (the game holds the item), or empty. + std::string locked; + // Why the value is not exact on the other side, or empty. + std::string inexact; + bool checked = false; +}; + +enum class SyncChange : uint8_t +{ + New, + Changed, + Removed, +}; + +struct SyncItem +{ + int group = 0; + int number = 0; + SyncChange change = SyncChange::Changed; + // The English name of the side that has the item (the file's when both have it). + std::string name; + // Changed: the fields that differ; New: the fields of the file's item. + std::vector fields; + // New and Removed rows. Changed rows apply their checked fields. + bool checked = false; + bool known = false; + std::string locked; +}; + +struct SyncFormula +{ + std::string entry; + std::optional current; + std::optional incoming; + bool known = false; + bool checked = false; +}; + +struct ItemSyncDiff +{ + std::vector items; + std::vector formulas; + // What was compared, as the window shows it: the items of both sides (each group and + // number once) and how many of them are the same; the formula entries likewise, while both + // sides have formulas. The same ones are not rows. + int comparedItems = 0; + int sameItems = 0; + int comparedFormulaEntries = 0; + int sameFormulaEntries = 0; + + bool IsEmpty() const + { + return items.empty() && formulas.empty(); + } +}; + +struct SyncOptions +{ + const KnownDifferences* known = nullptr; + // Names in other languages are not taken from the file. + std::span uiLocales; + // An item the game holds keeps its size and slot, is not removed, and a new one of its type + // is not added. + std::function isHeld; + // A comparison of two files: every language counts, also one that only one side has; a + // removed item lists its fields; a row of an item a file has twice names that file. + bool comparingFiles = false; +}; + +// The differences between the client's data (`client`, BuildItemExchange) and `file`. Checked +// by default: what the file changes; unchecked: removals (items and formula entries the file +// lacks), known differences, inexact and locked fields. A language one side lacks stays as it is +// on the other (unless comparingFiles). An item a side has more than once (its repeatedItems) is a +// locked row without fields. The formulas are compared while both sides have them. +ItemSyncDiff BuildItemSyncDiff(const Data::Items::ItemExchangeFile& client, const Data::Items::ItemExchangeFile& file, + const SyncOptions& options); + +// How many formula entries differ between two item stat files: the formula rows a diff lists. +int CountFormulaDifferences(const Data::Items::ItemStatFile& current, const Data::Items::ItemStatFile& incoming); + +// The client's data with the checked rows taken from the file. Formulas the item stat file's +// checks refuse are left as they were, with their problems in `issues`. +Data::Items::ItemExchangeFile ApplyItemSyncDiff(const Data::Items::ItemExchangeFile& client, + const Data::Items::ItemExchangeFile& file, const ItemSyncDiff& diff, + std::vector& issues); +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/KnownDifferences.cpp b/src/MuEditor/UI/ItemTools/KnownDifferences.cpp new file mode 100644 index 0000000000..f40fb32b4d --- /dev/null +++ b/src/MuEditor/UI/ItemTools/KnownDifferences.cpp @@ -0,0 +1,122 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "KnownDifferences.h" + +#include "Data/DataHandler/ItemData/ItemJsonStorage.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" + +#include +#include +#include + +namespace MuEditor::Items +{ +namespace +{ +using Data::Items::ItemDataIssue; +using Data::Items::ItemDataIssueSeverity; +using Data::Items::Json::OrderedJson; +using ItemKey = std::pair; + +constexpr int KnownDifferencesFormatVersion = 1; +} // namespace + +bool KnownDifferences::Contains(int group, int number, const std::string& field) const +{ + return fields.contains({group, number, field}); +} + +bool KnownDifferences::Contains(const std::string& formula) const +{ + return formulas.contains(formula); +} + +std::filesystem::path GetKnownDifferencesFile() +{ + return Data::Items::GetItemDataDirectory() / "Sync" / "KnownDifferences.json"; +} + +KnownDifferences LoadKnownDifferences(const std::filesystem::path& file, std::vector& issues) +{ + KnownDifferences known; + const std::optional text = Data::Items::Json::ReadTextFile(file); + if (!text) + { + return known; + } + const std::string source = file.generic_string(); + OrderedJson root; + if (!Data::Items::Json::ReadFileVersion(*text, source, KnownDifferencesFormatVersion, root, issues)) + { + return known; + } + const auto addError = [&](const std::string& field, const std::string& message) + { + issues.push_back( + {ItemDataIssueSeverity::Error, source, ItemDataIssue::NoItem, ItemDataIssue::NoItem, field, message}); + }; + if (const auto items = root.find("items"); items != root.end() && items->is_array()) + { + for (const OrderedJson& item : *items) + { + if (!item.is_object() || !item.contains("group") || !item.contains("number") || + !item["group"].is_number_integer() || !item["number"].is_number_integer() || !item.contains("fields") || + !item["fields"].is_array()) + { + addError("items", "an entry needs a group, a number and a list of fields"); + continue; + } + for (const OrderedJson& field : item["fields"]) + { + if (field.is_string()) + { + known.fields.insert( + {item["group"].get(), item["number"].get(), field.get()}); + } + } + } + } + if (const auto formulas = root.find("formulas"); formulas != root.end() && formulas->is_array()) + { + for (const OrderedJson& formula : *formulas) + { + if (formula.is_string()) + { + known.formulas.insert(formula.get()); + } + } + } + return known; +} + +std::string WriteKnownDifferencesJson(const KnownDifferences& known) +{ + OrderedJson root; + root["formatVersion"] = KnownDifferencesFormatVersion; + root["items"] = OrderedJson::array(); + std::map> byItem; + for (const auto& [group, number, field] : known.fields) + { + byItem[{group, number}].push_back(field); + } + for (const auto& [key, fields] : byItem) + { + OrderedJson item; + item["group"] = key.first; + item["number"] = key.second; + item["fields"] = fields; + root["items"].push_back(std::move(item)); + } + root["formulas"] = OrderedJson::array(); + for (const std::string& formula : known.formulas) + { + root["formulas"].push_back(formula); + } + const std::string text = root.dump(Data::Items::Json::Indent); + return Data::Items::Json::PutListEntriesOnOneLine(text, "items") + "\n"; +} +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/KnownDifferences.h b/src/MuEditor/UI/ItemTools/KnownDifferences.h new file mode 100644 index 0000000000..42b125ed87 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/KnownDifferences.h @@ -0,0 +1,36 @@ +#pragma once + +#ifdef _EDITOR + +#include "Data/GameData/ItemData/ItemDataIssue.h" + +#include +#include +#include +#include +#include +#include + +namespace MuEditor::Items +{ +// The items' fields and formula entries meant to differ between the client and a server +// (Data/Items/Sync/KnownDifferences.json): the diff shows them unchecked. +struct KnownDifferences +{ + // (group, number, field); an empty field names the item as a whole (new or removed). + std::set> fields; + std::set> formulas; + + bool Contains(int group, int number, const std::string& field) const; + bool Contains(const std::string& formula) const; + bool operator==(const KnownDifferences& other) const = default; +}; + +std::filesystem::path GetKnownDifferencesFile(); +// Nothing known when the file is not there. +KnownDifferences LoadKnownDifferences(const std::filesystem::path& file, + std::vector& issues); +std::string WriteKnownDifferencesJson(const KnownDifferences& known); +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp new file mode 100644 index 0000000000..be06aeb92d --- /dev/null +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -0,0 +1,1353 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "MuItemSyncUI.h" + +#include "ItemHeldByCharacter.h" +#include "ItemSelection.h" +#include "ItemSyncApply.h" +#include "ItemTextInput.h" +#include "ItemToolWindow.h" +#include "ItemUsageView.h" + +#include "../MuEditor/Core/MuEditorCore.h" +#include "../MuEditor/UI/Common/MuEditorWindowIds.h" +#include "../MuEditor/UI/Console/MuEditorConsoleUI.h" +#include "Data/DataHandler/ItemData/ItemJsonStorage.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" +#include "imgui.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace MuEditor::Items; +using Data::Items::ItemDataIssue; + +namespace +{ +// Pixel widths before the editor UI scale. +constexpr float FieldsColumnWidth = 80.0f; +constexpr float FieldColumnWidth = 220.0f; +constexpr float KnownColumnWidth = 60.0f; +constexpr float ItemListHeight = 260.0f; +constexpr float FieldListHeight = 220.0f; +constexpr float PopupTextWidth = 480.0f; +constexpr const char* NoValue = "-"; +constexpr const char* ExchangeFileName = "ItemExchange.json"; +// The name of the checkbox column: a header takes the ID of its label under PushID(column), so +// it must differ from the rows' "##Checked" under PushID(row), or row 0 has the header's ID. +constexpr const char* CheckColumn = "##CheckColumn"; +constexpr const char* ApplyPopupId = "###ItemSyncApply"; +constexpr const char* ExportPopupId = "###ItemSyncExport"; + +constexpr ImVec4 ErrorColor{1.0f, 0.4f, 0.4f, 1.0f}; +constexpr ImVec4 WarningColor{1.0f, 0.8f, 0.3f, 1.0f}; +// The green the map and dev editors use. +constexpr ImVec4 SuccessColor{0.5f, 1.0f, 0.5f, 1.0f}; + +// The paths are UTF-8 text in the window. +std::filesystem::path ToPath(const std::string& text) +{ + return std::filesystem::path(std::u8string(text.begin(), text.end())); +} + +std::string ToText(const std::filesystem::path& path) +{ + const std::u8string text = path.generic_u8string(); + return std::string(text.begin(), text.end()); +} + +float Scaled(float width) +{ + return width * g_MuEditorCore.GetUIScale(); +} + +// What the row does to the client's data. +const char* ChangeName(SyncChange change) +{ + switch (change) + { + case SyncChange::New: + return I18N::Editor::Added; + case SyncChange::Removed: + return I18N::Editor::Removed; + default: + return I18N::Editor::Changed; + } +} + +std::string ItemLabel(const SyncItem& row) +{ + return row.name + " (" + std::to_string(row.group) + "," + std::to_string(row.number) + ")"; +} + +// The fields a click on the row's box changes: New and Removed rows are one box. +bool IsWhole(const SyncItem& row) +{ + return row.change != SyncChange::Changed; +} + +bool IsRowChecked(const SyncItem& row) +{ + if (IsWhole(row)) + { + return row.checked; + } + return std::ranges::any_of(row.fields, [](const SyncField& field) { return field.checked; }); +} + +// Whether the row changes something when it is applied: the whole New or Removed item, or a +// field of a changed one. +bool AppliesSomething(const SyncItem& row) +{ + if (IsWhole(row)) + { + return row.checked && row.locked.empty(); + } + return std::ranges::any_of(row.fields, + [](const SyncField& field) { return field.checked && field.locked.empty(); }); +} + +// What a diff has unchecked, to leave it unchecked in the comparison after an Apply: the +// whole New and Removed items (field ""), the fields of the changed ones, the formula entries. +struct UncheckedRows +{ + std::set> fields; + std::set formulas; +}; + +UncheckedRows FindUnchecked(const ItemSyncDiff& diff) +{ + UncheckedRows unchecked; + for (const SyncItem& row : diff.items) + { + if (row.change != SyncChange::Changed) + { + if (!row.checked) + { + unchecked.fields.insert({row.group, row.number, ""}); + } + continue; + } + for (const SyncField& field : row.fields) + { + if (!field.checked) + { + unchecked.fields.insert({row.group, row.number, field.field}); + } + } + } + for (const SyncFormula& row : diff.formulas) + { + if (!row.checked) + { + unchecked.formulas.insert(row.entry); + } + } + return unchecked; +} + +void KeepUnchecked(ItemSyncDiff& diff, const UncheckedRows& unchecked) +{ + for (SyncItem& row : diff.items) + { + if (row.change != SyncChange::Changed) + { + row.checked = row.checked && !unchecked.fields.contains({row.group, row.number, ""}); + continue; + } + for (SyncField& field : row.fields) + { + field.checked = field.checked && !unchecked.fields.contains({row.group, row.number, field.field}); + } + } + for (SyncFormula& row : diff.formulas) + { + row.checked = row.checked && !unchecked.formulas.contains(row.entry); + } +} + +void SetRowChecked(SyncItem& row, bool checked) +{ + if (IsWhole(row)) + { + row.checked = checked && row.locked.empty(); + return; + } + for (SyncField& field : row.fields) + { + field.checked = checked && field.locked.empty(); + } +} + +void RenderValue(const std::optional& value) +{ + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(value ? value->c_str() : NoValue); + ImGui::PopTextWrapPos(); +} + +// The dialog's action is its userdata, so a dialog answers for its own button. +void* ToUserData(CMuItemSyncUI::FileAction action) +{ + return reinterpret_cast(static_cast(action)); +} + +void SDLCALL OnFileChosen(void* userdata, const char* const* files, int) +{ + // Nothing on an error, an empty list when the dialog was cancelled. + if (files != nullptr && files[0] != nullptr) + { + const auto action = static_cast(reinterpret_cast(userdata)); + g_MuItemSyncUI.ChooseFile(files[0], action); + } +} + +// The width of the column that says what a row does: its widest text. +float ChangeColumnWidth() +{ + float width = 0.0f; + for (const char* text : {I18N::Editor::InTheClient, I18N::Editor::FromAToB, I18N::Editor::Added, + I18N::Editor::Changed, I18N::Editor::Removed}) + { + width = std::max(width, ImGui::CalcTextSize(text).x); + } + return width; +} + +// Reads an exchange file into `file`, its problems into `issues`; false when it cannot be used (an +// item it has twice is no obstacle: the diff locks its row). +bool ReadExchangeFile(const std::filesystem::path& path, const std::string& name, Data::Items::ItemExchangeFile& file, + std::vector& issues) +{ + std::vector fileIssues; + if (const std::optional text = Data::Items::Json::ReadTextFile(path)) + { + ReadItemExchangeJson(*text, name, file, fileIssues); + } + else + { + fileIssues.push_back({Data::Items::ItemDataIssueSeverity::Error, name, ItemDataIssue::NoItem, + ItemDataIssue::NoItem, "", "could not be read"}); + } + const bool usable = Data::Items::CanImportItemExchange(file, fileIssues); + issues.insert(issues.end(), fileIssues.begin(), fileIssues.end()); + return usable; +} + +// How many rows add, change and remove an item. +void CountRows(const ItemSyncDiff& diff, int& added, int& changed, int& removed) +{ + added = changed = removed = 0; + for (const SyncItem& row : diff.items) + { + added += row.change == SyncChange::New ? 1 : 0; + changed += row.change == SyncChange::Changed ? 1 : 0; + removed += row.change == SyncChange::Removed ? 1 : 0; + } +} + +// The file's path, and the server it is from when OpenMU wrote it. +void RenderFileLine(const char* format, const std::string& path, const Data::Items::ItemExchangeFile& file) +{ + ImGui::TextUnformatted(I18N::Format(format, {path}).c_str()); + if (file.source == Data::Items::ItemExchangeSourceOpenMU) + { + ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FromOpenMUServer0, + {file.server.empty() ? std::string(NoValue) : file.server}) + .c_str()); + } +} + +// Whether both paths name one file (also when written differently); false while one is not there. +bool IsSameFile(const std::string& first, const std::string& second) +{ + std::error_code error; + return !first.empty() && !second.empty() && std::filesystem::equivalent(ToPath(first), ToPath(second), error); +} +} // namespace + +CMuItemSyncUI& CMuItemSyncUI::GetInstance() +{ + static CMuItemSyncUI instance; + return instance; +} + +void CMuItemSyncUI::Render(bool* open) +{ + if (ToolWindow::Begin(I18N::Editor::ItemSync, MuEditor::WindowIds::ItemSync, open)) + { + if (!m_knownLoaded) + { + std::vector issues; + m_known = LoadKnownDifferences(GetKnownDifferencesFile(), issues); + for (const ItemDataIssue& issue : issues) + { + g_MuEditorConsoleUI.LogEditor(issue.ToString()); + } + m_knownLoaded = true; + } + TakeChosenFile(); + RenderButtons(); + RenderExportResult(); + // An Apply whose comparison afterwards could not read the file still says what it did. + if (!m_diff && !m_result.empty()) + { + ImGui::Separator(); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(SuccessColor, "%s", m_result.c_str()); + ImGui::PopTextWrapPos(); + } + if (m_diff) + { + ImGui::Separator(); + RenderSummary(); + // A comparison of two files is read-only. + if (!m_comparingFiles) + { + RenderFieldToggle(); + } + // Both say how many were compared, also when none of them differs. + RenderItems(); + RenderFormulas(); + // Nothing to check or apply while both sides have the same data. + if (!m_comparingFiles && !m_diff->IsEmpty()) + { + RenderApply(); + } + } + RenderMessages(); + RenderApplyPopup(); + RenderExportPopup(); + } + ToolWindow::End(); +} + +void CMuItemSyncUI::ChooseFile(std::string path, FileAction action) +{ + const std::scoped_lock lock(m_chosenMutex); + m_chosenPath = std::move(path); + m_chosenAction = action; +} + +void CMuItemSyncUI::TakeChosenFile() +{ + // A confirmation is answered first: the file waits, so the confirmation keeps its diff. + if (m_confirmApply || m_pendingExport) + { + return; + } + std::optional path; + FileAction action = FileAction::Import; + { + const std::scoped_lock lock(m_chosenMutex); + path.swap(m_chosenPath); + action = m_chosenAction; + } + if (!path) + { + return; + } + switch (action) + { + case FileAction::Import: + Compare(ToPath(*path)); + break; + case FileAction::Export: + Export(ToPath(*path)); + break; + case FileAction::CompareA: + m_pathA = ToText(ToPath(*path)); + break; + case FileAction::CompareB: + m_pathB = ToText(ToPath(*path)); + break; + } +} + +void CMuItemSyncUI::OpenFileDialog(FileAction action) +{ + // SDL reads the filters until the dialog answers, so they stay; the label follows the editor's + // language. + static SDL_DialogFileFilter Filters[] = {{nullptr, "json"}}; + Filters[0].name = I18N::Editor::ItemExchangeFiles; + if (action == FileAction::Export) + { + SDL_ShowSaveFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, ExchangeFileName); + } + else + { + SDL_ShowOpenFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, nullptr, false); + } +} + +void CMuItemSyncUI::RenderButtons() +{ + if (ImGui::Button(I18N::Editor::Import)) + { + OpenFileDialog(FileAction::Import); + } + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Export)) + { + OpenFileDialog(FileAction::Export); + } + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", I18N::Editor::WhatImportAndExportDo); + ImGui::PopTextWrapPos(); + + // The path of an import can be typed too, e.g. where no file dialog is at hand. + ImGui::SetNextItemWidth(-Scaled(FieldColumnWidth)); + InputString(I18N::Editor::ExchangeFile, m_path); + ImGui::SameLine(); + ImGui::BeginDisabled(m_path.empty()); + if (ImGui::Button(I18N::Editor::CompareAgain)) + { + Compare(ToPath(m_path)); + } + ImGui::EndDisabled(); + RenderCompareFiles(); +} + +void CMuItemSyncUI::RenderCompareFiles() +{ + const std::string header = std::string(I18N::Editor::CompareTwoFiles) + "###CompareFiles"; + if (!ImGui::CollapsingHeader(header.c_str())) + { + return; + } + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", I18N::Editor::WhatComparingFilesDoes); + ImGui::PopTextWrapPos(); + for (const auto& [label, path, action] : {std::tuple{I18N::Editor::FileA, &m_pathA, FileAction::CompareA}, + std::tuple{I18N::Editor::FileB, &m_pathB, FileAction::CompareB}}) + { + ImGui::PushID(static_cast(action)); + ImGui::SetNextItemWidth(-Scaled(FieldColumnWidth)); + InputString(label, *path); + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Pick)) + { + OpenFileDialog(action); + } + ImGui::PopID(); + } + if (ImGui::Button(I18N::Editor::Swap)) + { + std::swap(m_pathA, m_pathB); + } + ImGui::SameLine(); + ImGui::BeginDisabled(m_pathA.empty() || m_pathB.empty()); + if (ImGui::Button(I18N::Editor::CompareTheFiles)) + { + CompareFiles(ToPath(m_pathA), ToPath(m_pathB)); + } + ImGui::EndDisabled(); +} + +void CMuItemSyncUI::RenderExportResult() +{ + if (m_exportResult.empty()) + { + return; + } + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(m_exportFailed ? ErrorColor : SuccessColor, "%s", m_exportResult.c_str()); + ImGui::PopTextWrapPos(); +} + +void CMuItemSyncUI::Compare(const std::filesystem::path& path) +{ + m_comparingFiles = false; + m_toggleFields.clear(); + m_path = ToText(path); + m_comparedPath = m_path; + m_result.clear(); + m_messages.clear(); + m_messagesAreErrors = false; + m_diff.reset(); + m_shownItem = -1; + m_shownUsage = {}; + m_file = {}; + + std::vector issues; + const bool usable = ReadExchangeFile(path, m_path, m_file, issues); + for (const ItemDataIssue& issue : issues) + { + m_messages.push_back(issue.ToString()); + } + if (!usable) + { + m_messagesAreErrors = true; + return; + } + + std::vector locales; + for (const char* locale : I18N::GetAvailableLocales()) + { + locales.emplace_back(locale); + } + const SyncOptions options{&m_known, locales, [](int itemType) { return IsHeldByCharacter(itemType); }}; + m_diff = BuildItemSyncDiff(BuildClientExchange(), m_file, options); + m_diffVersion = g_ItemDatabase.GetVersion(); + std::set names; + for (const SyncItem& row : m_diff->items) + { + if (row.change == SyncChange::Changed) + { + for (const SyncField& field : row.fields) + { + names.insert(field.field); + } + } + } + m_toggleFields.assign(names.begin(), names.end()); +} + +void CMuItemSyncUI::CompareFiles(const std::filesystem::path& a, const std::filesystem::path& b) +{ + m_comparingFiles = true; + m_toggleFields.clear(); + m_pathA = ToText(a); + m_pathB = ToText(b); + m_comparedA = m_pathA; + m_comparedB = m_pathB; + // No import is shown. + m_comparedPath.clear(); + m_confirmApply = false; + m_result.clear(); + m_messages.clear(); + m_messagesAreErrors = false; + m_diff.reset(); + m_shownItem = -1; + m_shownUsage = {}; + m_fileA = {}; + m_file = {}; + + std::vector issues; + const bool usableA = ReadExchangeFile(a, m_comparedA, m_fileA, issues); + const bool usableB = ReadExchangeFile(b, m_comparedB, m_file, issues); + for (const ItemDataIssue& issue : issues) + { + m_messages.push_back(issue.ToString()); + } + if (!usableA || !usableB) + { + m_messagesAreErrors = true; + return; + } + SyncOptions options; + options.known = &m_known; + options.comparingFiles = true; + m_diff = BuildItemSyncDiff(m_fileA, m_file, options); +} + +CMuItemSyncUI::ApplyBlocker CMuItemSyncUI::GetApplyBlocker() const +{ + if (m_path != m_comparedPath) + { + return ApplyBlocker::OtherPath; + } + // The diff shows the client's data as it was when it was made. + if (g_ItemDatabase.GetVersion() != m_diffVersion) + { + return ApplyBlocker::DataChanged; + } + const bool anyChecked = m_diff && (std::ranges::any_of(m_diff->items, AppliesSomething) || + std::ranges::any_of(m_diff->formulas, &SyncFormula::checked)); + return anyChecked ? ApplyBlocker::None : ApplyBlocker::NothingChecked; +} + +CMuItemSyncUI::CheckedChanges CMuItemSyncUI::CountCheckedChanges() const +{ + CheckedChanges changes; + if (!m_diff) + { + return changes; + } + for (const SyncItem& row : m_diff->items) + { + if (!AppliesSomething(row)) + { + continue; + } + switch (row.change) + { + case SyncChange::New: + ++changes.added; + break; + case SyncChange::Changed: + ++changes.changed; + break; + case SyncChange::Removed: + ++changes.removed; + break; + } + } + changes.formulaEntries = static_cast(std::ranges::count_if(m_diff->formulas, &SyncFormula::checked)); + return changes; +} + +bool CMuItemSyncUI::Apply() +{ + if (m_comparingFiles || !m_diff || GetApplyBlocker() != ApplyBlocker::None) + { + return false; + } + // What is applied, for the line that reports it. + const CheckedChanges changes = CountCheckedChanges(); + + std::vector issues; + if (!ApplyItemSyncImport(*m_diff, m_file, issues)) + { + m_result.clear(); + m_messages.clear(); + m_messages.push_back(I18N::Editor::TheChecksRefuseTheChange); + bool formulasRefused = false; + for (const ItemDataIssue& issue : issues) + { + m_messages.push_back(issue.ToString()); + // The rows of the items with errors are unchecked; an error of no item is one of the + // formulas, which an issue cannot trace back to its row: all of them are unchecked. + formulasRefused = formulasRefused || issue.group == ItemDataIssue::NoItem; + for (SyncItem& row : m_diff->items) + { + if (row.group == issue.group && row.number == issue.number) + { + SetRowChecked(row, false); + } + } + } + if (formulasRefused) + { + for (SyncFormula& row : m_diff->formulas) + { + row.checked = false; + } + } + m_messagesAreErrors = true; + return false; + } + const std::string done = I18N::Format( + I18N::Editor::Applied0ItemsAnd1FormulaEntries, + {std::to_string(changes.added + changes.changed + changes.removed), std::to_string(changes.formulaEntries)}); + const std::string path = m_comparedPath; + g_MuEditorConsoleUI.LogEditor(path + ": " + done); + // The differences that are left; what was not checked stays unchecked, so a second click + // does not take it after all. + const UncheckedRows unchecked = FindUnchecked(*m_diff); + Compare(ToPath(path)); + if (m_diff) + { + KeepUnchecked(*m_diff, unchecked); + } + m_result = done; + return true; +} + +void CMuItemSyncUI::Export(const std::filesystem::path& path) +{ + m_exportPath = ToText(path); + m_exportResult.clear(); + m_exportFailed = false; + m_pendingExport.reset(); + const ItemSyncExportPreview preview = PreviewItemSyncExport(path); + if (!preview.fileExists) + { + WriteExport(); + return; + } + if (preview.ChangesNothing()) + { + m_exportResult = I18N::Format(I18N::Editor::TheFileAlreadyHasTheClientData, {m_exportPath}); + return; + } + // A file that is there is replaced only after the confirmation (RenderExportPopup). + m_pendingExport = preview; +} + +bool CMuItemSyncUI::ConfirmExport() +{ + if (!m_pendingExport) + { + return false; + } + m_pendingExport.reset(); + return WriteExport(); +} + +void CMuItemSyncUI::CancelExport() +{ + m_pendingExport.reset(); +} + +bool CMuItemSyncUI::WriteExport() +{ + std::vector issues; + size_t writtenItems = 0; + if (!WriteItemSyncExport(ToPath(m_exportPath), writtenItems, issues)) + { + m_exportFailed = true; + m_exportResult = I18N::Editor::TheExportCouldNotBeWritten; + for (const ItemDataIssue& issue : issues) + { + m_exportResult += "\n" + issue.ToString(); + g_MuEditorConsoleUI.LogEditor(issue.ToString()); + } + return false; + } + m_exportFailed = false; + m_exportResult = I18N::Format(I18N::Editor::Exported0ItemsTo1, {std::to_string(writtenItems), m_exportPath}); + g_MuEditorConsoleUI.LogEditor(m_exportResult); + // A comparison of that file (also one that could not read it) shows what it held before. + if (m_comparingFiles && (IsSameFile(m_comparedA, m_exportPath) || IsSameFile(m_comparedB, m_exportPath))) + { + CompareFiles(ToPath(m_comparedA), ToPath(m_comparedB)); + } + else if (!m_comparingFiles && IsSameFile(m_comparedPath, m_exportPath)) + { + Compare(ToPath(m_comparedPath)); + } + return true; +} + +void CMuItemSyncUI::SetAllChecked(bool checked) +{ + for (SyncItem& row : m_diff->items) + { + SetRowChecked(row, checked); + } + for (SyncFormula& row : m_diff->formulas) + { + row.checked = checked; + } +} + +void CMuItemSyncUI::RenderSummary() +{ + int added = 0; + int changed = 0; + int removed = 0; + CountRows(*m_diff, added, changed, removed); + if (m_comparingFiles) + { + RenderFileLine(I18N::Editor::FileA0, m_comparedA, m_fileA); + RenderFileLine(I18N::Editor::FileB0, m_comparedB, m_file); + ImGui::PushTextWrapPos(0.0f); + if (m_diff->IsEmpty()) + { + ImGui::TextColored(SuccessColor, "%s", + m_fileA.formulas && m_file.formulas ? I18N::Editor::TheFilesMatch + : I18N::Editor::TheFilesMatchInAllItems); + } + else + { + ImGui::TextUnformatted(I18N::Format(I18N::Editor::TheFileDiffCounts, + {std::to_string(added), std::to_string(changed), + std::to_string(removed), std::to_string(m_diff->formulas.size())}) + .c_str()); + } + ImGui::PopTextWrapPos(); + return; + } + RenderFileLine(I18N::Editor::ImportFrom0, m_comparedPath, m_file); + ImGui::PushTextWrapPos(0.0f); + // What the last Apply did comes before what it left. + if (!m_result.empty()) + { + ImGui::TextColored(SuccessColor, "%s", m_result.c_str()); + } + if (m_diff->IsEmpty()) + { + // The client always has formulas, so they were compared when the file has them too. + ImGui::TextColored(SuccessColor, "%s", + m_file.formulas ? I18N::Editor::TheFileMatchesTheClient + : I18N::Editor::TheFileMatchesTheClientInAllItems); + ImGui::TextDisabled("%s", I18N::Editor::HowToSeeDifferences); + } + else + { + if (!m_result.empty()) + { + ImGui::TextDisabled("%s", I18N::Editor::StillListedBelowTheRowsThatWereNotChecked); + } + ImGui::TextUnformatted(I18N::Format(I18N::Editor::TheDiffCounts, + {std::to_string(added), std::to_string(changed), std::to_string(removed), + std::to_string(m_diff->formulas.size())}) + .c_str()); + } + if (g_ItemDatabase.GetVersion() != m_diffVersion) + { + ImGui::TextColored(WarningColor, "%s", I18N::Editor::TheClientDataChangedSinceTheComparison); + } + ImGui::PopTextWrapPos(); + if (m_diff->IsEmpty()) + { + return; + } + if (ImGui::SmallButton(I18N::Editor::CheckAll)) + { + SetAllChecked(true); + } + ImGui::SameLine(); + if (ImGui::SmallButton(I18N::Editor::CheckNone)) + { + SetAllChecked(false); + } +} + +void CMuItemSyncUI::RenderFieldToggle() +{ + if (m_toggleFields.empty()) + { + return; + } + if (std::ranges::find(m_toggleFields, m_toggleField) == m_toggleFields.end()) + { + m_toggleField = m_toggleFields.front(); + } + ImGui::SetNextItemWidth(Scaled(FieldColumnWidth)); + if (ImGui::BeginCombo(I18N::Editor::AFieldInAllItems, m_toggleField.c_str())) + { + for (const std::string& name : m_toggleFields) + { + if (ImGui::Selectable(name.c_str(), name == m_toggleField)) + { + m_toggleField = name; + } + } + ImGui::EndCombo(); + } + for (const bool checked : {true, false}) + { + ImGui::SameLine(); + if (ImGui::SmallButton(checked ? I18N::Editor::CheckThem : I18N::Editor::UncheckThem)) + { + for (SyncItem& row : m_diff->items) + { + for (SyncField& field : row.fields) + { + if (row.change == SyncChange::Changed && field.field == m_toggleField) + { + field.checked = checked && field.locked.empty(); + } + } + } + } + } +} + +void CMuItemSyncUI::RenderItems() +{ + if (m_diff->items.empty()) + { + if (m_diff->comparedItems > 0) + { + const char* format = + m_comparingFiles ? I18N::Editor::ItemsAll0AreTheSameInBothFiles : I18N::Editor::ItemsAll0AreTheSame; + ImGui::TextDisabled("%s", I18N::Format(format, {std::to_string(m_diff->sameItems)}).c_str()); + } + return; + } + const std::string header = + I18N::Format(I18N::Editor::ItemsThatDiffer0Of1, + {std::to_string(m_diff->items.size()), std::to_string(m_diff->comparedItems)}) + + "###SyncItems"; + if (!ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + // A comparison of two files has no boxes. + const bool checkable = !m_comparingFiles; + constexpr ImGuiTableFlags Flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; + if (ImGui::BeginTable("##SyncItems", checkable ? 4 : 3, Flags, ImVec2(0.0f, Scaled(ItemListHeight)))) + { + ImGui::TableSetupScrollFreeze(0, 1); + if (checkable) + { + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + } + ImGui::TableSetupColumn(checkable ? I18N::Editor::InTheClient : I18N::Editor::FromAToB, + ImGuiTableColumnFlags_WidthFixed, ChangeColumnWidth()); + ImGui::TableSetupColumn(I18N::Editor::Item); + ImGui::TableSetupColumn(I18N::Editor::Fields, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldsColumnWidth)); + ImGui::TableHeadersRow(); + ImGuiListClipper clipper; + clipper.Begin(static_cast(m_diff->items.size())); + while (clipper.Step()) + { + for (int index = clipper.DisplayStart; index < clipper.DisplayEnd; ++index) + { + SyncItem& row = m_diff->items[static_cast(index)]; + ImGui::PushID(index); + ImGui::TableNextRow(); + if (checkable) + { + ImGui::TableNextColumn(); + bool checked = IsRowChecked(row); + ImGui::BeginDisabled(!row.locked.empty()); + if (ImGui::Checkbox("##Checked", &checked)) + { + SetRowChecked(row, checked); + } + ImGui::EndDisabled(); + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(ChangeName(row.change)); + ImGui::TableNextColumn(); + if (row.known) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + } + if (ImGui::Selectable(ItemLabel(row).c_str(), m_shownItem == index, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) + { + m_shownItem = index; + const int itemType = Data::Items::MakeItemType(row.group, row.number); + if (g_ItemDatabase.Find(itemType) != nullptr) + { + g_ItemSelection.Select(itemType); + } + const bool removedFromClient = checkable && row.change == SyncChange::Removed; + m_shownUsage = removedFromClient ? FindItemUsage(itemType) : ItemUsage{}; + } + if (row.known) + { + ImGui::PopStyleColor(); + } + ImGui::TableNextColumn(); + ImGui::Text("%d", static_cast(row.fields.size())); + ImGui::PopID(); + } + } + ImGui::EndTable(); + } + if (m_shownItem >= 0 && m_shownItem < static_cast(m_diff->items.size())) + { + RenderItemFields(m_diff->items[static_cast(m_shownItem)]); + } +} + +void CMuItemSyncUI::RenderItemFields(SyncItem& row) +{ + ImGui::TextUnformatted(I18N::Format(I18N::Editor::FieldsOf0, {ItemLabel(row)}).c_str()); + if (!row.locked.empty()) + { + ImGui::TextColored(WarningColor, "%s", row.locked.c_str()); + } + // What Remove item lists before it removes an item (not for a comparison of two files). + if (!m_comparingFiles && row.change == SyncChange::Removed) + { + ImGui::TextUnformatted(I18N::Editor::TheItemAndItsModelEntryAreRemoved); + if (m_shownUsage.GoesWithItem()) + { + ImGui::TextUnformatted(I18N::Editor::RemovedWithIt); + UsageView::RenderGoingWithItem(m_shownUsage); + } + ImGui::TextUnformatted(I18N::Editor::WhatUsesItStaysAtItsNumber); + UsageView::RenderUsesAtNumber(m_shownUsage); + } + if (!m_comparingFiles && row.change == SyncChange::New) + { + const auto incoming = std::ranges::find_if(m_file.items, [&](const Data::Items::ItemDefinition& item) + { return item.group == row.group && item.number == row.number; }); + if (incoming != m_file.items.end()) + { + for (const std::string& note : DescribeNewItem(*incoming)) + { + ImGui::TextColored(WarningColor, "%s", note.c_str()); + } + } + } + if (IsWhole(row)) + { + bool known = row.known; + if (ImGui::Checkbox(I18N::Editor::Known, &known)) + { + SetKnown(row.group, row.number, "", known); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); + } + } + if (row.fields.empty()) + { + return; + } + constexpr ImGuiTableFlags Flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; + const bool changed = row.change == SyncChange::Changed; + // A comparison of two files has no boxes, and its sides are file A and file B; an item only + // file A has lists file A's values. + const bool checkable = !m_comparingFiles; + const bool removed = row.change == SyncChange::Removed; + const int columns = (changed ? 5 : 2) + (checkable ? 1 : 0); + if (!ImGui::BeginTable("##SyncFields", columns, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) + { + return; + } + ImGui::TableSetupScrollFreeze(0, 1); + if (checkable) + { + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + } + ImGui::TableSetupColumn(I18N::Editor::Field, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); + if (changed) + { + ImGui::TableSetupColumn(checkable ? I18N::Editor::Now : I18N::Editor::FileA); + } + ImGui::TableSetupColumn(removed ? I18N::Editor::FileA + : checkable ? I18N::Editor::FromTheFile + : I18N::Editor::FileB); + if (changed) + { + ImGui::TableSetupColumn(I18N::Editor::Note); + ImGui::TableSetupColumn(I18N::Editor::Known, ImGuiTableColumnFlags_WidthFixed, Scaled(KnownColumnWidth)); + } + ImGui::TableHeadersRow(); + for (size_t index = 0; index < row.fields.size(); ++index) + { + SyncField& field = row.fields[index]; + ImGui::PushID(static_cast(index)); + ImGui::TableNextRow(); + if (checkable) + { + ImGui::TableNextColumn(); + if (changed) + { + ImGui::BeginDisabled(!field.locked.empty()); + ImGui::Checkbox("##Checked", &field.checked); + ImGui::EndDisabled(); + } + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(field.field.c_str()); + if (changed) + { + ImGui::TableNextColumn(); + RenderValue(field.current); + } + ImGui::TableNextColumn(); + RenderValue(removed ? field.current : field.incoming); + if (changed) + { + ImGui::TableNextColumn(); + const std::string& note = !field.locked.empty() ? field.locked : field.inexact; + if (!note.empty()) + { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextColored(WarningColor, "%s", note.c_str()); + ImGui::PopTextWrapPos(); + } + ImGui::TableNextColumn(); + bool known = field.known; + if (ImGui::Checkbox("##Known", &known)) + { + SetKnown(row.group, row.number, field.field, known); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); + } + } + ImGui::PopID(); + } + ImGui::EndTable(); +} + +void CMuItemSyncUI::RenderFormulas() +{ + const bool checkable = !m_comparingFiles; + // A file without formulas leaves the client's as they are; two files are compared by formula + // while both have them. + if (!m_file.formulas || (m_comparingFiles && !m_fileA.formulas)) + { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", m_comparingFiles ? I18N::Editor::FormulasNotComparedAFileHasNone + : I18N::Editor::FormulasNotComparedTheFileHasNone); + ImGui::PopTextWrapPos(); + return; + } + if (m_diff->formulas.empty()) + { + if (m_diff->comparedFormulaEntries > 0) + { + const char* format = m_comparingFiles ? I18N::Editor::FormulaEntriesAll0AreTheSameInBothFiles + : I18N::Editor::FormulaEntriesAll0AreTheSame; + ImGui::TextDisabled("%s", I18N::Format(format, {std::to_string(m_diff->sameFormulaEntries)}).c_str()); + } + return; + } + const std::string header = + I18N::Format(I18N::Editor::FormulaEntriesThatDiffer0Of1, + {std::to_string(m_diff->formulas.size()), std::to_string(m_diff->comparedFormulaEntries)}) + + "###SyncFormulas"; + if (!ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + constexpr ImGuiTableFlags Flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; + if (!ImGui::BeginTable("##SyncFormulas", checkable ? 5 : 4, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) + { + return; + } + ImGui::TableSetupScrollFreeze(0, 1); + if (checkable) + { + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + } + ImGui::TableSetupColumn(I18N::Editor::Entry, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); + ImGui::TableSetupColumn(checkable ? I18N::Editor::Now : I18N::Editor::FileA); + ImGui::TableSetupColumn(checkable ? I18N::Editor::FromTheFile : I18N::Editor::FileB); + ImGui::TableSetupColumn(I18N::Editor::Known, ImGuiTableColumnFlags_WidthFixed, Scaled(KnownColumnWidth)); + ImGui::TableHeadersRow(); + for (size_t index = 0; index < m_diff->formulas.size(); ++index) + { + SyncFormula& row = m_diff->formulas[index]; + ImGui::PushID(static_cast(index)); + ImGui::TableNextRow(); + if (checkable) + { + ImGui::TableNextColumn(); + ImGui::Checkbox("##Checked", &row.checked); + } + ImGui::TableNextColumn(); + ImGui::TextUnformatted(row.entry.c_str()); + ImGui::TableNextColumn(); + RenderValue(row.current); + ImGui::TableNextColumn(); + RenderValue(row.incoming); + ImGui::TableNextColumn(); + bool known = row.known; + if (ImGui::Checkbox("##Known", &known)) + { + SetKnownFormula(row.entry, known); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); + } + ImGui::PopID(); + } + ImGui::EndTable(); +} + +void CMuItemSyncUI::RenderApply() +{ + const ApplyBlocker blocker = GetApplyBlocker(); + ImGui::BeginDisabled(blocker != ApplyBlocker::None); + // It asks first (RenderApplyPopup). + if (ImGui::Button(I18N::Editor::ApplyTheCheckedChanges)) + { + m_confirmApply = true; + } + ImGui::EndDisabled(); + ImGui::PushTextWrapPos(0.0f); + switch (blocker) + { + case ApplyBlocker::None: + ImGui::TextDisabled("%s", I18N::Editor::AfterApplyingTheWindowComparesAgain); + break; + case ApplyBlocker::OtherPath: + ImGui::TextColored(WarningColor, "%s", I18N::Editor::ThePathIsNotTheComparedFile); + break; + case ApplyBlocker::NothingChecked: + ImGui::TextColored(WarningColor, "%s", I18N::Editor::NothingIsChecked); + break; + case ApplyBlocker::DataChanged: + // The summary says so. + break; + } + ImGui::PopTextWrapPos(); +} + +void CMuItemSyncUI::RenderApplyPopup() +{ + // Open while it waits for its answer, also when ImGui closed it (e.g. the editor was hidden). + if (m_confirmApply && !m_diff) + { + m_confirmApply = false; + } + if (m_confirmApply && !ImGui::IsPopupOpen(ApplyPopupId)) + { + ImGui::OpenPopup(ApplyPopupId); + } + const std::string title = std::string(I18N::Editor::ApplyTheImport) + ApplyPopupId; + if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + return; + } + const CheckedChanges changes = CountCheckedChanges(); + ImGui::PushTextWrapPos(Scaled(PopupTextWidth)); + ImGui::TextUnformatted(I18N::Format(I18N::Editor::TheImportChanges, + {m_comparedPath, std::to_string(changes.added), std::to_string(changes.changed), + std::to_string(changes.removed), std::to_string(changes.formulaEntries)}) + .c_str()); + ImGui::TextDisabled("%s", I18N::Editor::WhatTheImportLeaves); + ImGui::PopTextWrapPos(); + ImGui::BeginDisabled(GetApplyBlocker() != ApplyBlocker::None); + if (ImGui::Button(I18N::Editor::Apply)) + { + m_confirmApply = false; + Apply(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Cancel)) + { + m_confirmApply = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void CMuItemSyncUI::RenderExportPopup() +{ + // Open while the export waits for its answer, also when ImGui closed it. + if (m_pendingExport && !ImGui::IsPopupOpen(ExportPopupId)) + { + ImGui::OpenPopup(ExportPopupId); + } + const std::string title = std::string(I18N::Editor::ReplaceTheExchangeFile) + ExportPopupId; + if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize)) + { + return; + } + if (!m_pendingExport) + { + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return; + } + const ItemSyncExportPreview& preview = *m_pendingExport; + std::string text; + if (!preview.readable) + { + text = I18N::Format(I18N::Editor::TheFileCannotBeReadReplaceIt, {m_exportPath}); + } + else if (preview.fileHasFormulas) + { + text = I18N::Format(I18N::Editor::TheExportChanges, + {m_exportPath, std::to_string(preview.added), std::to_string(preview.changed), + std::to_string(preview.removed), std::to_string(preview.formulaEntries)}); + } + else + { + text = I18N::Format(I18N::Editor::TheExportChangesTheFormulasAreAdded, + {m_exportPath, std::to_string(preview.added), std::to_string(preview.changed), + std::to_string(preview.removed)}); + } + ImGui::PushTextWrapPos(Scaled(PopupTextWidth)); + ImGui::TextUnformatted(text.c_str()); + ImGui::PopTextWrapPos(); + if (ImGui::Button(I18N::Editor::Replace)) + { + ConfirmExport(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Cancel)) + { + CancelExport(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void CMuItemSyncUI::RenderMessages() +{ + for (const std::string& message : m_messages) + { + ImGui::PushTextWrapPos(0.0f); + if (m_messagesAreErrors) + { + ImGui::TextColored(ErrorColor, "%s", message.c_str()); + } + else + { + ImGui::TextUnformatted(message.c_str()); + } + ImGui::PopTextWrapPos(); + } +} + +void CMuItemSyncUI::SetKnown(int group, int number, const std::string& field, bool known) +{ + if (known) + { + m_known.fields.insert({group, number, field}); + } + else + { + m_known.fields.erase({group, number, field}); + } + for (SyncItem& row : m_diff->items) + { + if (row.group != group || row.number != number) + { + continue; + } + if (field.empty()) + { + row.known = known; + row.checked = !known && row.locked.empty() && row.change == SyncChange::New; + } + for (SyncField& entry : row.fields) + { + if (entry.field == field) + { + entry.known = known; + entry.checked = !known && entry.locked.empty() && entry.inexact.empty(); + } + } + } + SaveKnownDifferences(); +} + +void CMuItemSyncUI::SetKnownFormula(const std::string& entry, bool known) +{ + if (known) + { + m_known.formulas.insert(entry); + } + else + { + m_known.formulas.erase(entry); + } + for (SyncFormula& row : m_diff->formulas) + { + if (row.entry == entry) + { + row.known = known; + row.checked = !known; + } + } + SaveKnownDifferences(); +} + +void CMuItemSyncUI::SaveKnownDifferences() +{ + std::vector issues; + Data::Items::WriteItemTextFile(GetKnownDifferencesFile(), WriteKnownDifferencesJson(m_known), issues); + for (const ItemDataIssue& issue : issues) + { + g_MuEditorConsoleUI.LogEditor(issue.ToString()); + } +} + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h new file mode 100644 index 0000000000..f83a259fd8 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -0,0 +1,187 @@ +#pragma once + +#ifdef _EDITOR + +#include "ItemSyncApply.h" +#include "ItemSyncDiff.h" +#include "ItemUsage.h" + +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemExchangeFile.h" + +#include +#include +#include +#include +#include +#include + +// The Item Sync window (phase 7, section 9's Item Sync): Import and Export of the item exchange +// file with OpenMU. Import lists how the file differs from the client's items and item formulas +// (ItemSyncDiff); after a confirmation that says what changes, the checked rows are applied as +// one step of the undo history. The known differences (Data/Items/Sync/KnownDifferences.json) +// stay unchecked; a row can be added to them or taken out. Export writes the client's items and +// formulas into a file without a diff (OpenMU's import page shows one); a file that is there is +// replaced after a confirmation that says what changes in it. Compare two files lists, read-only, +// what changes from one exchange file to another (e.g. a client export and a server export); its +// rows can be marked as known differences too. +class CMuItemSyncUI +{ +public: + static CMuItemSyncUI& GetInstance(); + + void Render(bool* open); + + // Compares the client's data with the file at `path`, as Import does after its dialog. + void Compare(const std::filesystem::path& path); + // Compares two exchange files (Compare the files): what changes from `a` to `b`, in every + // language, read-only. + void CompareFiles(const std::filesystem::path& a, const std::filesystem::path& b); + bool IsComparingFiles() const + { + return m_comparingFiles; + } + + // Why the checked rows cannot be applied now. + enum class ApplyBlocker + { + None, + // The path field holds another file than the one compared. + OtherPath, + // The client's items or formulas changed since the comparison. + DataChanged, + NothingChecked, + }; + ApplyBlocker GetApplyBlocker() const; + + // What the checked rows change, for the confirmation before they are applied. + struct CheckedChanges + { + int added = 0; + int changed = 0; + int removed = 0; + int formulaEntries = 0; + }; + CheckedChanges CountCheckedChanges() const; + + // Applies the checked rows (the confirmation's Apply); false when nothing changed, also while + // a blocker holds. + bool Apply(); + + // Export after its dialog: writes the client's items and formulas into `path`. A file that is + // there is replaced only after ConfirmExport (the popup with what changes in it), and not at + // all when it already has the client's data. + void Export(const std::filesystem::path& path); + bool ConfirmExport(); + void CancelExport(); + // The export waiting for its confirmation. + const std::optional& GetPendingExport() const + { + return m_pendingExport; + } + // What the last export did, shown under the buttons. + const std::string& GetExportResult() const + { + return m_exportResult; + } + + // The button whose file dialog answers. + enum class FileAction : uint8_t + { + Import, + Export, + // The two files of Compare two files. + CompareA, + CompareB, + }; + // Takes the path a file dialog chose, on the dialog's thread; the window uses it in its next + // frame without a confirmation open. + void ChooseFile(std::string path, FileAction action); + // The diff shown, for its checkboxes. + std::optional& GetDiff() + { + return m_diff; + } + const std::vector& GetMessages() const + { + return m_messages; + } + // What the last Apply did, shown above the diff it left; empty after any other comparison. + const std::string& GetResult() const + { + return m_result; + } + +private: + CMuItemSyncUI() = default; + + void TakeChosenFile(); + void SetAllChecked(bool checked); + void OpenFileDialog(FileAction action); + void RenderButtons(); + void RenderCompareFiles(); + void RenderExportResult(); + void RenderSummary(); + void RenderFieldToggle(); + void RenderItems(); + void RenderItemFields(MuEditor::Items::SyncItem& row); + void RenderFormulas(); + void RenderApply(); + void RenderApplyPopup(); + void RenderExportPopup(); + void RenderMessages(); + bool WriteExport(); + void SetKnown(int group, int number, const std::string& field, bool known); + void SetKnownFormula(const std::string& entry, bool known); + void SaveKnownDifferences(); + + // The path field, and the file the diff is of: Apply uses that one. + std::string m_path; + std::string m_comparedPath; + // The file an import compares with the client, or file B of a comparison of two files. + Data::Items::ItemExchangeFile m_file; + // A comparison of two files is shown instead of an import: its path fields, the files it is + // of, and file A. + bool m_comparingFiles = false; + std::string m_pathA; + std::string m_pathB; + std::string m_comparedA; + std::string m_comparedB; + Data::Items::ItemExchangeFile m_fileA; + std::optional m_diff; + MuEditor::Items::KnownDifferences m_known; + bool m_knownLoaded = false; + // g_ItemDatabase.GetVersion() when the diff was made (a formula change counts there too). + int m_diffVersion = 0; + // The row of m_diff->items whose fields are shown, or -1, and what uses the item when the + // import removes it. + int m_shownItem = -1; + MuEditor::Items::ItemUsage m_shownUsage; + // The fields of the changed items, for "A field in all items" (built with the diff), and the + // one picked there. + std::vector m_toggleFields; + std::string m_toggleField; + std::vector m_messages; + bool m_messagesAreErrors = false; + std::string m_result; + // Apply was clicked: the confirmation is open (again, should ImGui close it otherwise) until + // it is answered. + bool m_confirmApply = false; + + // The export: the file it writes, what changes in it while the confirmation waits (which is + // open while it does), and what the last one did. + std::string m_exportPath; + std::optional m_pendingExport; + std::string m_exportResult; + bool m_exportFailed = false; + + // The file dialogs answer on their own thread: the path waits here, with the action of the + // dialog that chose it. + std::mutex m_chosenMutex; + std::optional m_chosenPath; + FileAction m_chosenAction = FileAction::Import; +}; + +#define g_MuItemSyncUI CMuItemSyncUI::GetInstance() + +#endif // _EDITOR diff --git a/src/bin/Data/Items/Group12_Wing.json b/src/bin/Data/Items/Group12_Wing.json index dced5b944e..71a87f17df 100644 --- a/src/bin/Data/Items/Group12_Wing.json +++ b/src/bin/Data/Items/Group12_Wing.json @@ -532,11 +532,12 @@ }, "width": 1, "height": 1, + "statProfile": "orb", "level": 8, "buyPrice": 800, "repairable": false, "requirements": { - "energy": 100 + "energy": 52 }, "classRequirements": { "fairyElf": 1 @@ -552,11 +553,12 @@ }, "width": 1, "height": 1, + "statProfile": "orb", "level": 13, "buyPrice": 3000, "repairable": false, "requirements": { - "energy": 100 + "energy": 72 }, "classRequirements": { "fairyElf": 1 @@ -572,11 +574,12 @@ }, "width": 1, "height": 1, + "statProfile": "orb", "level": 18, "buyPrice": 7000, "repairable": false, "requirements": { - "energy": 100 + "energy": 92 }, "classRequirements": { "fairyElf": 1 @@ -709,11 +712,12 @@ }, "width": 1, "height": 1, + "statProfile": "orb", "level": 60, "buyPrice": 51000, "repairable": false, "requirements": { - "strength": 320 + "strength": 596 }, "classRequirements": { "magicGladiator": 1 @@ -749,11 +753,12 @@ }, "width": 1, "height": 1, + "statProfile": "orb", "level": 81, "buyPrice": 195000, "repairable": false, "requirements": { - "dexterity": 258 + "dexterity": 646 }, "classRequirements": { "fairyElf": 2 @@ -789,10 +794,11 @@ }, "width": 1, "height": 2, + "statProfile": "orb", "level": 74, "buyPrice": 115000, "requirements": { - "energy": 20 + "energy": 79 }, "classRequirements": { "darkLord": 1 @@ -808,10 +814,11 @@ }, "width": 1, "height": 2, + "statProfile": "orb", "level": 98, "buyPrice": 375000, "requirements": { - "energy": 34, + "energy": 153, "leadership": 400 }, "classRequirements": { @@ -828,10 +835,11 @@ }, "width": 1, "height": 2, + "statProfile": "orb", "level": 82, "buyPrice": 220000, "requirements": { - "energy": 25, + "energy": 102, "leadership": 300 }, "classRequirements": { @@ -848,10 +856,11 @@ }, "width": 1, "height": 2, + "statProfile": "orb", "level": 92, "buyPrice": 295000, "requirements": { - "energy": 29, + "energy": 126, "leadership": 340 }, "classRequirements": { @@ -986,10 +995,11 @@ }, "width": 1, "height": 2, + "statProfile": "orb", "level": 102, "buyPrice": 300000, "requirements": { - "energy": 32, + "energy": 150, "leadership": 70 }, "classRequirements": { @@ -1612,11 +1622,12 @@ }, "width": 1, "height": 1, + "statProfile": "orb", "level": 100, "buyPrice": 250000, "requirements": { "level": 220, - "energy": 37 + "energy": 168 }, "classRequirements": { "fairyElf": 2 @@ -1651,11 +1662,12 @@ }, "width": 1, "height": 2, + "statProfile": "orb", "level": 100, "buyPrice": 380000, "requirements": { "level": 220, - "energy": 16 + "energy": 84 }, "classRequirements": { "darkLord": 1 diff --git a/src/bin/Data/Items/Group15_Etc.json b/src/bin/Data/Items/Group15_Etc.json index aefd932d65..0be06f243c 100644 --- a/src/bin/Data/Items/Group15_Etc.json +++ b/src/bin/Data/Items/Group15_Etc.json @@ -19,7 +19,7 @@ "buyPrice": 17000, "repairable": false, "requirements": { - "energy": 100 + "energy": 140 }, "classRequirements": { "darkWizard": 1, @@ -43,7 +43,7 @@ "buyPrice": 11000, "repairable": false, "requirements": { - "energy": 100 + "energy": 104 }, "classRequirements": { "darkWizard": 1, @@ -68,7 +68,7 @@ "buyPrice": 3000, "repairable": false, "requirements": { - "energy": 100 + "energy": 72 }, "classRequirements": { "darkWizard": 1, @@ -92,7 +92,7 @@ "buyPrice": 300, "repairable": false, "requirements": { - "energy": 100 + "energy": 40 }, "classRequirements": { "darkWizard": 1, @@ -117,7 +117,7 @@ "buyPrice": 21000, "repairable": false, "requirements": { - "energy": 100 + "energy": 160 }, "classRequirements": { "darkWizard": 1, @@ -141,7 +141,7 @@ "buyPrice": 5000, "repairable": false, "requirements": { - "energy": 100 + "energy": 88 }, "classRequirements": { "darkWizard": 1 @@ -164,7 +164,7 @@ "buyPrice": 14000, "repairable": false, "requirements": { - "energy": 100 + "energy": 120 }, "classRequirements": { "darkWizard": 1, @@ -189,7 +189,7 @@ "buyPrice": 25000, "repairable": false, "requirements": { - "energy": 100 + "energy": 180 }, "classRequirements": { "darkWizard": 1, @@ -213,7 +213,7 @@ "buyPrice": 35000, "repairable": false, "requirements": { - "energy": 100 + "energy": 220 }, "classRequirements": { "darkWizard": 1, @@ -237,7 +237,7 @@ "buyPrice": 60000, "repairable": false, "requirements": { - "energy": 100 + "energy": 260 }, "classRequirements": { "darkWizard": 1, @@ -261,7 +261,7 @@ "buyPrice": 1150, "repairable": false, "requirements": { - "energy": 100 + "energy": 56 }, "classRequirements": { "darkWizard": 1, @@ -286,7 +286,7 @@ "buyPrice": 100000, "repairable": false, "requirements": { - "energy": 110 + "energy": 345 }, "classRequirements": { "darkWizard": 1, @@ -310,7 +310,7 @@ "buyPrice": 175000, "repairable": false, "requirements": { - "energy": 150 + "energy": 500 }, "classRequirements": { "darkWizard": 1, @@ -334,7 +334,7 @@ "buyPrice": 265000, "repairable": false, "requirements": { - "energy": 200 + "energy": 724 }, "classRequirements": { "darkWizard": 1, @@ -358,7 +358,7 @@ "buyPrice": 245000, "repairable": false, "requirements": { - "energy": 188 + "energy": 644 }, "classRequirements": { "darkWizard": 2 @@ -381,7 +381,7 @@ "buyPrice": 135000, "repairable": false, "requirements": { - "energy": 126 + "energy": 408 }, "classRequirements": { "darkWizard": 1 @@ -404,7 +404,7 @@ "buyPrice": 345000, "repairable": false, "requirements": { - "energy": 243 + "energy": 953 }, "classRequirements": { "darkWizard": 2 @@ -427,7 +427,7 @@ "buyPrice": 315000, "repairable": false, "requirements": { - "energy": 223 + "energy": 849 }, "classRequirements": { "darkWizard": 2 @@ -450,7 +450,7 @@ "buyPrice": 410000, "repairable": false, "requirements": { - "energy": 258 + "energy": 1052 }, "classRequirements": { "darkWizard": 2 @@ -473,7 +473,7 @@ "buyPrice": 175000, "repairable": false, "requirements": { - "energy": 75 + "energy": 245 }, "classRequirements": { "summoner": 1 @@ -496,7 +496,7 @@ "buyPrice": 100000, "repairable": false, "requirements": { - "energy": 93 + "energy": 150 }, "classRequirements": { "summoner": 1 @@ -519,7 +519,7 @@ "buyPrice": 315000, "repairable": false, "requirements": { - "energy": 216 + "energy": 823 }, "classRequirements": { "summoner": 1 @@ -542,7 +542,7 @@ "buyPrice": 245000, "repairable": false, "requirements": { - "energy": 111 + "energy": 375 }, "classRequirements": { "summoner": 1 @@ -565,7 +565,7 @@ "buyPrice": 265000, "repairable": false, "requirements": { - "energy": 181 + "energy": 620 }, "classRequirements": { "summoner": 1 @@ -588,7 +588,7 @@ "buyPrice": 135000, "repairable": false, "requirements": { - "energy": 100 + "energy": 180 }, "classRequirements": { "summoner": 1 @@ -611,7 +611,7 @@ "buyPrice": 410000, "repairable": false, "requirements": { - "energy": 173 + "energy": 663 }, "classRequirements": { "summoner": 2 @@ -634,7 +634,7 @@ "buyPrice": 450000, "repairable": false, "requirements": { - "energy": 201 + "energy": 912 }, "classRequirements": { "summoner": 2 @@ -658,7 +658,7 @@ "repairable": false, "requirements": { "level": 220, - "energy": 118 + "energy": 1058 }, "classRequirements": { "darkWizard": 2 @@ -682,7 +682,7 @@ "repairable": false, "requirements": { "level": 220, - "energy": 118 + "energy": 1058 }, "classRequirements": { "magicGladiator": 1 @@ -798,7 +798,7 @@ "repairable": false, "requirements": { "level": 120, - "energy": 80 + "energy": 404 }, "classRequirements": { "rageFighter": 1 @@ -822,7 +822,7 @@ "repairable": false, "requirements": { "level": 80, - "energy": 35 + "energy": 132 }, "classRequirements": { "rageFighter": 1 @@ -846,7 +846,7 @@ "repairable": false, "requirements": { "level": 50, - "energy": 30 + "energy": 80 }, "classRequirements": { "rageFighter": 1 diff --git a/src/bin/Data/Items/Stats/ItemStats.json b/src/bin/Data/Items/Stats/ItemStats.json index 99da49a9fc..b7d4ff4860 100644 --- a/src/bin/Data/Items/Stats/ItemStats.json +++ b/src/bin/Data/Items/Stats/ItemStats.json @@ -158,6 +158,15 @@ "levelPerItemLevel": 4 } }, + { + "name": "orb", + "requirements": { + "strength": "base", + "dexterity": "base", + "energy": "base", + "vitality": "base" + } + }, { "name": "sceptre", "magicPower": {"perLevelAfterHalving": 0}, @@ -169,7 +178,10 @@ "name": "scroll", "requirements": { "levelPerItemLevel": 4, - "energy": {"add": 20, "levelWeight": 3, "percent": 4, "basis": "levelRequirement"} + "strength": "base", + "dexterity": "base", + "energy": "base", + "vitality": "base" } }, { @@ -221,6 +233,7 @@ {"profile": "secondTierWing", "slot": ["wings"], "wingTier": ["second"]}, {"profile": "thirdTierWing", "slot": ["wings"], "wingTier": ["third"]}, {"profile": "default", "slot": ["wings"]}, + {"profile": "orb", "group": [12], "requirements": ["strength", "dexterity", "energy", "vitality", "leadership"]}, {"profile": "scroll", "group": [15], "requirements": ["level", "strength", "dexterity", "energy", "vitality", "leadership"]}, {"profile": "helper", "group": [13, 14], "requirements": ["level"]} ] diff --git a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp index a62ade5179..ef2d12733b 100644 --- a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp @@ -4,9 +4,11 @@ #include "ItemDataHandler.h" #include "ItemDataLoader.h" #include "Data/GameData/ItemData/ItemAttributeConversion.h" +#include "Data/GameData/ItemData/ItemStatProfiles.h" #include "Data/GameData/ItemData/ItemType.h" #include "Core/Text/Utf8.h" +#include #include #include #include @@ -310,6 +312,136 @@ void ImportItem(const std::vector& files, int itemType, ItemBmdImp } } // namespace +namespace +{ +constexpr int MaxRequirement = 65535; + +// The stat requirements the original client grew from a weight: in the definition and in the +// bmd record. +struct GrownRequirement +{ + ItemRequirement requirement; + ItemRequirementStat stat; + WORD ItemRequirements::* value; + WORD ITEM_ATTRIBUTE::* weight; +}; +constexpr GrownRequirement GrownRequirements[] = { + {ItemRequirement::Strength, ItemRequirementStat::Strength, &ItemRequirements::strength, + &ITEM_ATTRIBUTE::RequireStrength}, + {ItemRequirement::Dexterity, ItemRequirementStat::Dexterity, &ItemRequirements::dexterity, + &ITEM_ATTRIBUTE::RequireDexterity}, + {ItemRequirement::Energy, ItemRequirementStat::Energy, &ItemRequirements::energy, &ITEM_ATTRIBUTE::RequireEnergy}, + {ItemRequirement::Vitality, ItemRequirementStat::Vitality, &ItemRequirements::vitality, + &ITEM_ATTRIBUTE::RequireVitality}, +}; + +// The original client's requirement growth, which its bmd files hold the weights for: its code, so +// it stays as it was also when the item stat file's formulas change. +// add + weight * drop level * percent / 100, energy with a percent of its own; a scroll's energy +// (group 15) from its level requirement where it has one. +constexpr long long OriginalRequirementAdd = 20; +constexpr long long OriginalRequirementPercent = 3; +constexpr long long OriginalEnergyRequirementPercent = 4; +constexpr long long PercentDivisor = 100; + +// What the original client asked for with `weight`. +int GrowWeight(const ItemDefinition& definition, ItemRequirement requirement, int weight) +{ + if (weight == 0) + { + return 0; + } + const bool energy = requirement == ItemRequirement::Energy; + const bool fromLevelRequirement = energy && definition.group == ITEM_GROUP_ETC && definition.requirements.level > 0; + const long long basis = fromLevelRequirement ? definition.requirements.level : definition.level; + const long long percent = energy ? OriginalEnergyRequirementPercent : OriginalRequirementPercent; + return static_cast( + std::min(OriginalRequirementAdd + weight * basis * percent / PercentDivisor, MaxRequirement)); +} +} // namespace + +namespace +{ +// The profile the item uses: the one it names, or the one its rules give. +const ItemStatProfile& FindItemProfile(const ItemStatProfiles& profiles, const ItemDefinition& definition) +{ + const std::string name = + definition.statProfile.empty() ? DeriveStatProfile(profiles, definition) : definition.statProfile; + const int profile = profiles.Find(name); + return profiles.Get(profile == ItemStatProfiles::UnknownProfile ? ItemStatProfiles::DefaultProfile : profile); +} + +// Whether the item keeps the requirement as a value where the original client grew a weight. +bool HoldsWeightInBmd(const ItemStatProfiles& profiles, const ItemStatProfile& profile, ItemRequirementStat stat) +{ + return profile.requirements.Get(stat).keepBase && + !profiles.Get(ItemStatProfiles::DefaultProfile).requirements.Get(stat).keepBase; +} +} // namespace + +bool HasRequirementWeightsInBmd(const ItemStatProfiles& profiles, const ItemDefinition& definition) +{ + const ItemStatProfile& profile = FindItemProfile(profiles, definition); + return std::ranges::any_of(GrownRequirements, [&](const GrownRequirement& grown) + { return HoldsWeightInBmd(profiles, profile, grown.stat); }); +} + +void ToRequirementValues(const ItemStatProfiles& profiles, ItemDefinition& definition) +{ + const ItemStatProfile& profile = FindItemProfile(profiles, definition); + for (const GrownRequirement& grown : GrownRequirements) + { + if (HoldsWeightInBmd(profiles, profile, grown.stat)) + { + WORD& value = definition.requirements.*grown.value; + value = static_cast(GrowWeight(definition, grown.requirement, value)); + } + } +} + +std::vector ToBmdRequirementWeights(const ItemStatProfiles& profiles, const ItemDefinition& definition, + ITEM_ATTRIBUTE& attribute) +{ + std::vector inexact; + const ItemStatProfile& profile = FindItemProfile(profiles, definition); + for (const GrownRequirement& grown : GrownRequirements) + { + if (!HoldsWeightInBmd(profiles, profile, grown.stat)) + { + continue; + } + const int value = definition.requirements.*grown.value; + // The weights grow the value without going down: the smallest that reaches it. + int low = 0; + int high = MaxRequirement; + while (low < high) + { + const int middle = low + (high - low) / 2; + if (GrowWeight(definition, grown.requirement, middle) < value) + { + low = middle + 1; + } + else + { + high = middle; + } + } + int weight = low; + const int reached = GrowWeight(definition, grown.requirement, weight); + if (reached != value) + { + // Past the value, or none reaches it: the nearer of this weight and the one below. + if (weight > 0 && value - GrowWeight(definition, grown.requirement, weight - 1) <= reached - value) + { + --weight; + } + inexact.push_back(grown.requirement); + } + attribute.*grown.weight = static_cast(weight); + } + return inexact; +} + std::span GetItemBmdLanguages() { return GetLegacyLanguages(); diff --git a/src/source/Data/DataHandler/ItemData/ItemBmdImport.h b/src/source/Data/DataHandler/ItemData/ItemBmdImport.h index 8a576cbc62..2e85dc93bb 100644 --- a/src/source/Data/DataHandler/ItemData/ItemBmdImport.h +++ b/src/source/Data/DataHandler/ItemData/ItemBmdImport.h @@ -3,6 +3,7 @@ #include "Data/DataHandler/LegacyLanguages.h" #include "Data/GameData/ItemData/ItemDataIssue.h" #include "Data/GameData/ItemData/ItemDefinition.h" +#include "Data/GameData/ItemData/ItemStructs.h" #include #include @@ -28,6 +29,23 @@ using ItemBmdLanguage = LegacyLanguage; // provides the stats. std::span GetItemBmdLanguages(); +class ItemStatProfiles; + +// A stat requirement whose formula the item's stat profile (the one it names, or the one its rules +// give) keeps the base value of, where the default grows it, is kept as the value the game asks +// for, as OpenMU does: with the shipped formulas, those of the orbs, crystals and scrolls (the orb +// and scroll profiles). The original client's bmd files hold weights for them, which its code grew +// like any item's: 20 + weight * drop level * 3 / 100 (energy * 4), and a scroll's energy (group +// 15) from its level requirement where it has one: 20 + weight * level requirement * 4 / 100. +// Leadership is the value in both. An orb given the default profile holds weights like any item. +bool HasRequirementWeightsInBmd(const ItemStatProfiles& profiles, const ItemDefinition& definition); +// Import from bmd: the weights of such requirements become the values. +void ToRequirementValues(const ItemStatProfiles& profiles, ItemDefinition& definition); +// Export as bmd: the smallest weight that gives each value of such requirements, written into +// `attribute`. Returns the requirements no weight gives exactly; they get the nearest one. +std::vector ToBmdRequirementWeights(const ItemStatProfiles& profiles, const ItemDefinition& definition, + ITEM_ATTRIBUTE& attribute); + // A field whose value was replaced because a name ran into it. struct ItemBmdRepair { diff --git a/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp b/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp index 9d3926be15..7337b460fd 100644 --- a/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp @@ -1,5 +1,6 @@ #include "stdafx.h" #include "ItemDataHandler.h" +#include "ItemBmdImport.h" #include "ItemJsonStorage.h" #include "ItemModelLoader.h" #include "Core/Globals/_struct.h" @@ -11,6 +12,7 @@ #include "Data/GameData/EffectData/GlowColorList.h" #include "Data/GameData/ItemData/ItemDataValidation.h" #include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemEnumNames.h" #include "Data/GameData/ItemData/ItemModelDatabase.h" #include "Data/GameData/ItemData/ItemModelJsonFormat.h" #include "Data/GameData/ItemData/ItemTypeUse.h" @@ -64,6 +66,41 @@ std::unique_ptr BuildItemAttributes(std::string_view locale) return attributes; } +#ifdef _EDITOR +// The orbs', crystals' and scrolls' requirements as the bmd files hold them, weights +// (ToBmdRequirementWeights), with a line of `notes` for each one no weight gives exactly. +void WriteBmdRequirementWeights(const ItemDefinition& definition, ITEM_ATTRIBUTE& attribute, std::string& notes) +{ + const ItemStatProfiles& profiles = g_ItemDatabase.GetStatProfiles(); + if (!HasRequirementWeightsInBmd(profiles, definition)) + { + return; + } + for (const ItemRequirement requirement : ToBmdRequirementWeights(profiles, definition, attribute)) + { + notes += g_ItemDatabase.GetLogName(MakeItemType(definition.group, definition.number)) + + ": no weight gives its " + FindEnumName(requirement) + + " requirement exactly, the nearest one is written\n"; + } +} + +// The records as the bmd files hold them: with the item names of `locale`, and the +// requirements WriteBmdRequirementWeights turns into weights. +std::unique_ptr BuildBmdItemAttributes(std::string_view locale, std::string& notes) +{ + auto attributes = BuildItemAttributes(locale); + for (const ItemDefinition& definition : g_ItemDatabase.GetAllSlots()) + { + if (definition.Exists()) + { + WriteBmdRequirementWeights(definition, attributes[MakeItemType(definition.group, definition.number)], + notes); + } + } + return attributes; +} +#endif + #ifdef _EDITOR // The tables and profiles in the writer's order (by name), so a file read as it // was written and one the editor stored compare equal (the undo history). @@ -236,7 +273,8 @@ void CItemDataHandler::RefreshItemNames() } #ifdef _EDITOR -ItemDefinition CItemDataHandler::WithDerivedFields(const ItemDefinition& definition, const ItemDefinition* previous) +ItemDefinition CItemDataHandler::WithDerivedFields(const ItemDefinition& definition, const ItemDefinition* previous, + StatProfileStore profiles) { ItemDefinition result = definition; if (previous == nullptr || definition.weaponKind == previous->weaponKind) @@ -248,7 +286,8 @@ ItemDefinition CItemDataHandler::WithDerivedFields(const ItemDefinition& definit // "Follow the data" was picked: also over a profile picked before. result.statProfile = FollowStatProfile(g_ItemDatabase.GetStatProfiles(), nullptr, result); } - else if (previous == nullptr || definition.statProfile == previous->statProfile) + else if (profiles == StatProfileStore::Follow && + (previous == nullptr || definition.statProfile == previous->statProfile)) { result.statProfile = FollowStatProfile(g_ItemDatabase.GetStatProfiles(), previous, result); } @@ -270,7 +309,7 @@ void CItemDataHandler::SetItem(const ItemDefinition& definition) SetItems(std::span(&definition, 1)); } -void CItemDataHandler::SetItems(std::span definitions) +void CItemDataHandler::SetItems(std::span definitions, StatProfileStore profiles) { std::vector stored; stored.reserve(definitions.size()); @@ -278,7 +317,8 @@ void CItemDataHandler::SetItems(std::span definitions) { if (IsValidItemId(definition.group, definition.number)) { - stored.push_back(WithDerivedFields(definition, g_ItemDatabase.Find(definition.group, definition.number))); + stored.push_back( + WithDerivedFields(definition, g_ItemDatabase.Find(definition.group, definition.number), profiles)); } } g_ItemDatabase.SetMany(stored); @@ -658,6 +698,12 @@ ItemBmdImportResult CItemDataHandler::ImportFromBmd() result.keptEnglishNameCount = KeepCurrentEnglishNames(result.items); KeepMissingNamesMissing(result.items, result.importedLocales); KeepFieldsNotInBmd(result.items, result.importedLocales); + // With its stat profile known, a requirement the item keeps as a value takes the value the + // bmd file's weight gave in the original client. + for (ItemDefinition& item : result.items) + { + ToRequirementValues(g_ItemDatabase.GetStatProfiles(), item); + } ValidateItems(result.items, result.validationIssues); ValidateStatProfiles(result.items, g_ItemDatabase.GetStatProfiles(), GetItemStatsFile().generic_string(), result.validationIssues); @@ -748,9 +794,12 @@ bool CItemDataHandler::ExportAsBmd(std::string& changeLog) } bool success = true; + // The same for every language. + std::string weightNotes; for (const ItemBmdLanguage& language : GetItemBmdLanguages()) { - const auto attributes = BuildItemAttributes(language.locale); + weightNotes.clear(); + const auto attributes = BuildBmdItemAttributes(language.locale, weightNotes); std::string languageChangeLog; const bool saved = ItemDataSaver::Save(GetItemFilePath(language.folder).c_str(), attributes.get(), &languageChangeLog); @@ -759,12 +808,14 @@ bool CItemDataHandler::ExportAsBmd(std::string& changeLog) success = (saved || unchanged) && success; changeLog += languageChangeLog; } + changeLog += weightNotes; return success; } -bool CItemDataHandler::ExportAsS6E3(wchar_t* fileName, std::string& problems) +bool CItemDataHandler::ExportAsS6E3(wchar_t* fileName, std::string& problems, std::string& notes) { - return ItemDataExportS6E3::SaveLegacy(fileName, BuildItemAttributes(I18N::GetCurrentLocale()).get(), &problems); + return ItemDataExportS6E3::SaveLegacy(fileName, BuildBmdItemAttributes(I18N::GetCurrentLocale(), notes).get(), + &problems); } bool CItemDataHandler::ExportToCsv(wchar_t* fileName) diff --git a/src/source/Data/DataHandler/ItemData/ItemDataHandler.h b/src/source/Data/DataHandler/ItemData/ItemDataHandler.h index 6941adfbdc..6c64d513a0 100644 --- a/src/source/Data/DataHandler/ItemData/ItemDataHandler.h +++ b/src/source/Data/DataHandler/ItemData/ItemDataHandler.h @@ -12,6 +12,7 @@ #include "Data/DataHandler/ItemData/ItemBmdImport.h" #include "Data/DataHandler/ItemData/ItemJsonStorage.h" #include "Data/DataHandler/ItemData/ItemModelLoader.h" +#include "Data/GameData/ItemData/ItemAttributeConversion.h" #include "Data/GameData/ItemData/ItemDataIssue.h" #include "Data/GameData/ItemData/ItemDataValidation.h" @@ -58,8 +59,10 @@ class CItemDataHandler // unless the definition changes them itself: then they are the user's pick. // An empty stat profile is no pick: it follows the data. void SetItem(const Data::Items::ItemDefinition& definition); - // Like SetItem for several items, with the database lists built once. - void SetItems(std::span definitions); + // Like SetItem for several items, with the database lists built once; AsGiven keeps the stat + // profile each item names as a pick. + void SetItems(std::span definitions, + Data::Items::StatProfileStore profiles = Data::Items::StatProfileStore::Follow); // Stores the definitions exactly as they are, nothing derived (the undo of // the item tools puts back what an edit stored), and refills their rows of // ItemAttribute[]. @@ -184,8 +187,9 @@ class CItemDataHandler bool ExportAsBmd(std::string& changeLog); // Writes the S6E3 legacy file, with the item names of the UI locale, also while translated item names are off; - // an item with a value the file cannot hold is named in `problems` instead. - bool ExportAsS6E3(wchar_t* fileName, std::string& problems); + // an item with a value the file cannot hold is named in `problems` instead. `notes` gets a line for each + // requirement the bmd cannot hold exactly (ToBmdRequirementWeights); so does ExportAsBmd's change log. + bool ExportAsS6E3(wchar_t* fileName, std::string& problems, std::string& notes); bool ExportToCsv(wchar_t* fileName); #endif @@ -239,9 +243,11 @@ class CItemDataHandler void TakeBackModelMoves(); // The definition SetItem stores: the weapon kind and the stat profile // follow the data unless `definition` changes them from `previous` (the - // item as it is, or the one a new item copies; nullptr: none). - static Data::Items::ItemDefinition WithDerivedFields(const Data::Items::ItemDefinition& definition, - const Data::Items::ItemDefinition* previous); + // item as it is, or the one a new item copies; nullptr: none), or the + // profile is AsGiven. + static Data::Items::ItemDefinition + WithDerivedFields(const Data::Items::ItemDefinition& definition, const Data::Items::ItemDefinition* previous, + Data::Items::StatProfileStore profiles = Data::Items::StatProfileStore::Follow); // ItemAttribute[itemType] from the item database: the item, or all zero. static void RefillItemAttribute(int itemType); diff --git a/src/source/Data/DataHandler/ItemData/ItemJsonStorage.cpp b/src/source/Data/DataHandler/ItemData/ItemJsonStorage.cpp index d6a5161db3..10c9b52721 100644 --- a/src/source/Data/DataHandler/ItemData/ItemJsonStorage.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemJsonStorage.cpp @@ -296,6 +296,13 @@ ItemDataSaveResult SaveItemDataDirectory(const std::filesystem::path& directory, return SaveItemAndModelDataDirectories(directory, items, ItemModelSave{}, issues); } +ItemDataSaveResult WriteItemTextFile(const std::filesystem::path& file, const std::string& text, + std::vector& issues) +{ + const FileToSave files[] = {{file, text}}; + return WriteChangedFiles(files, issues); +} + ItemDataSaveResult SaveItemAndModelDataDirectories(const std::filesystem::path& itemDirectory, std::span items, const ItemModelSave& models, std::vector& issues) diff --git a/src/source/Data/DataHandler/ItemData/ItemJsonStorage.h b/src/source/Data/DataHandler/ItemData/ItemJsonStorage.h index 0881dc6af0..5059923d5c 100644 --- a/src/source/Data/DataHandler/ItemData/ItemJsonStorage.h +++ b/src/source/Data/DataHandler/ItemData/ItemJsonStorage.h @@ -97,6 +97,11 @@ using ItemGroupSet = std::bitset; ItemDataSaveResult SaveItemDataDirectory(const std::filesystem::path& directory, std::span items, std::vector& issues); +// Writes `text` to `file` as the saves do: by a temporary file that replaces it, so a failed +// write leaves the old file. An unchanged file is not touched. +ItemDataSaveResult WriteItemTextFile(const std::filesystem::path& file, const std::string& text, + std::vector& issues); + // The item models a save writes with the items. struct ItemModelSave { diff --git a/src/source/Data/GameData/ItemData/ItemAttributeConversion.h b/src/source/Data/GameData/ItemData/ItemAttributeConversion.h index 91e6da3fce..46315b9a82 100644 --- a/src/source/Data/GameData/ItemData/ItemAttributeConversion.h +++ b/src/source/Data/GameData/ItemData/ItemAttributeConversion.h @@ -3,6 +3,7 @@ #include "Data/GameData/ItemData/ItemDefinition.h" #include "Data/GameData/ItemData/ItemStructs.h" +#include #include // Converts between the legacy ITEM_ATTRIBUTE record (item.bmd layout, and the @@ -57,6 +58,15 @@ std::string DeriveStatProfile(const ItemStatProfiles& profiles, const ItemDefini std::string FollowStatProfile(const ItemStatProfiles& profiles, const ItemDefinition* previous, const ItemDefinition& updated); +// How storing an item treats the stat profile it names (not "", which follows the data): Follow +// keeps it only while it differs from the one the item had, as a pick of the item tools; AsGiven +// keeps every one as given, for an import that decided each item's profile itself. +enum class StatProfileStore : std::uint8_t +{ + Follow, + AsGiven, +}; + // The attribute's name becomes the neutral (English) name. ItemDefinition ToItemDefinition(const ITEM_ATTRIBUTE& attribute, int itemType); diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp new file mode 100644 index 0000000000..892ff4956e --- /dev/null +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp @@ -0,0 +1,345 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "ItemExchangeFile.h" + +#include "ItemJsonCommon.h" +#include "ItemJsonFormat.h" +#include "ItemStatProfiles.h" +#include "ItemStatsJson.h" +#include "ItemType.h" + +#include +#include +#include + +namespace Data::Items +{ +namespace +{ +using Json::OrderedJson; + +namespace Keys +{ +constexpr const char* FormatVersion = "formatVersion"; +constexpr const char* Source = "source"; +constexpr const char* Server = "server"; +constexpr const char* GameVersion = "gameVersion"; +constexpr const char* Formulas = "formulas"; +constexpr const char* Items = "items"; +constexpr const char* Group = "group"; +} // namespace Keys + +const Json::KeySet RootKeys = {Keys::FormatVersion, Keys::Source, Keys::Server, + Keys::GameVersion, Keys::Formulas, Keys::Items}; + +bool LessByKey(const ItemDefinition& left, const ItemDefinition& right) +{ + return std::pair(left.group, left.number) < std::pair(right.group, right.number); +} + +class ExchangeReader +{ +public: + ExchangeReader(const std::string& source, std::vector& issues) : m_source(source), m_issues(issues) + { + } + + void Read(const OrderedJson& root, ItemExchangeFile& file); + +private: + void AddIssue(ItemDataIssueSeverity severity, int group, int number, const std::string& field, + const std::string& message) + { + m_issues.push_back({severity, m_source, group, number, field, message}); + } + + // A text of `root`; false when it is missing (and required) or not a text. + bool ReadText(const OrderedJson& root, const char* key, bool required, std::string& value); + void ReadItems(const OrderedJson& root, ItemExchangeFile& file); + void ReadItem(const OrderedJson& json, size_t index, std::vector& items); + + const std::string& m_source; + std::vector& m_issues; +}; + +bool ExchangeReader::ReadText(const OrderedJson& root, const char* key, bool required, std::string& value) +{ + const auto field = root.find(key); + if (field == root.end()) + { + if (required) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, key, "missing"); + } + return false; + } + if (!field->is_string()) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, key, "must be a text"); + return false; + } + if (field->get_ref().empty()) + { + // An empty optional text is as if it were not there (the writer leaves it out). + if (required) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, key, "must be a text"); + } + return false; + } + value = field->get(); + return true; +} + +void ExchangeReader::Read(const OrderedJson& root, ItemExchangeFile& file) +{ + Json::WarnAboutUnknownKeys( + root, RootKeys, "", [this](const std::string& field, const std::string& message) + { AddIssue(ItemDataIssueSeverity::Warning, ItemDataIssue::NoItem, ItemDataIssue::NoItem, field, message); }); + + std::string text; + if (ReadText(root, Keys::Source, true, text)) + { + if (text != ItemExchangeSourceMuMain && text != ItemExchangeSourceOpenMU) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, Keys::Source, + std::string("must be \"") + ItemExchangeSourceMuMain + "\" or \"" + ItemExchangeSourceOpenMU + + "\""); + } + file.source = text; + } + ReadText(root, Keys::Server, false, file.server); + if (ReadText(root, Keys::GameVersion, true, text)) + { + if (text != ItemExchangeGameVersion) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, Keys::GameVersion, + std::string("only \"") + ItemExchangeGameVersion + "\" data can be exchanged"); + } + file.gameVersion = text; + } + + if (const auto formulas = root.find(Keys::Formulas); formulas != root.end()) + { + ItemStatFile statFile; + ReadItemStatFormulasJson(*formulas, m_source, statFile, m_issues); + file.formulas = std::move(statFile); + } + ReadItems(root, file); +} + +void ExchangeReader::ReadItems(const OrderedJson& root, ItemExchangeFile& file) +{ + const auto list = root.find(Keys::Items); + if (list == root.end() || !list->is_array()) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, Keys::Items, + "missing or not a list"); + return; + } + std::vector items; + for (size_t index = 0; index < list->size(); ++index) + { + ReadItem((*list)[index], index, items); + } + + // A (group, number) that is there twice (OpenMU does not keep it unique): neither is read. + std::map, int> counts; + for (const ItemDefinition& item : items) + { + ++counts[{item.group, item.number}]; + } + for (const auto& [key, count] : counts) + { + if (count > 1) + { + AddIssue(ItemDataIssueSeverity::Error, key.first, key.second, "", + "is in the file " + std::to_string(count) + " times; none of them is read"); + file.repeatedItems.push_back(key); + } + } + std::erase_if(items, [&](const ItemDefinition& item) { return counts[{item.group, item.number}] > 1; }); + std::ranges::sort(items, LessByKey); + file.items = std::move(items); +} + +void ExchangeReader::ReadItem(const OrderedJson& json, size_t index, std::vector& items) +{ + const std::string field = std::string(Keys::Items) + "[" + std::to_string(index) + "]"; + if (!json.is_object()) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, field, + "an item must be an object"); + return; + } + const auto groupField = json.find(Keys::Group); + long long group = 0; + if (groupField == json.end() || !groupField->is_number_integer() || !Json::ReadWholeNumber(*groupField, group) || + group < 0 || group >= MAX_ITEM_TYPE) + { + AddIssue(ItemDataIssueSeverity::Error, ItemDataIssue::NoItem, ItemDataIssue::NoItem, field + "." + Keys::Group, + "missing or not a group from 0 to " + std::to_string(MAX_ITEM_TYPE - 1)); + return; + } + + // The rest is an item of the item files. + OrderedJson itemJson = json; + itemJson.erase(Keys::Group); + ItemDefinition item; + if (!ReadItemJson(itemJson, m_source, static_cast(group), item, m_issues)) + { + return; + } + ItemDefinition shared = ToExchangeItem(item); + if (item.statProfile.empty()) + { + item.statProfile = shared.statProfile; + } + if (!(shared == item)) + { + AddIssue(ItemDataIssueSeverity::Warning, item.group, item.number, "", + "has fields the exchange file does not have (they stay with the client); they are left out"); + } + items.push_back(std::move(shared)); +} +} // namespace + +ExchangeItemIndex IndexExchangeItems(const ItemExchangeFile& file) +{ + ExchangeItemIndex items; + for (const ItemDefinition& item : file.items) + { + items[{item.group, item.number}] = &item; + } + return items; +} + +void CopyExchangeFields(const ItemDefinition& from, ItemDefinition& to) +{ + to.names = from.names; + for (const ItemTag tag : ExchangeTags) + { + to.tags.Set(tag, from.tags.Has(tag)); + } + to.width = from.width; + to.height = from.height; + to.slot = from.slot; + to.skill = from.skill; + to.level = from.level; + to.durability = from.durability; + to.magicDurability = from.magicDurability; + to.damageMin = from.damageMin; + to.damageMax = from.damageMax; + to.blockRate = from.blockRate; + to.defense = from.defense; + to.attackSpeed = from.attackSpeed; + to.walkSpeed = from.walkSpeed; + to.magicPower = from.magicPower; + to.excellentBonus = from.excellentBonus; + to.excellentRaisesLevel = from.excellentRaisesLevel; + to.requirements = from.requirements; + to.requirementsByLevel = from.requirementsByLevel; + to.classRequirements = from.classRequirements; + to.resistances = from.resistances; + to.sellValue = from.sellValue; + to.buyPrice = from.buyPrice; + to.tradable = from.tradable; + to.droppable = from.droppable; + to.storable = from.storable; + to.sellable = from.sellable; + to.personalShopSellable = from.personalShopSellable; + to.repairable = from.repairable; +} + +ItemDefinition ToExchangeItem(const ItemDefinition& item) +{ + ItemDefinition shared; + shared.group = item.group; + shared.number = item.number; + CopyExchangeFields(item, shared); + shared.statProfile = item.statProfile.empty() ? std::string(StatProfileNames::Default) : item.statProfile; + return shared; +} + +ItemExchangeFile BuildItemExchange(std::span items, const ItemStatFile& formulas) +{ + ItemExchangeFile file; + file.formulas = formulas; + file.formulas->profileRules.clear(); + for (const ItemDefinition& item : items) + { + if (item.Exists()) + { + file.items.push_back(ToExchangeItem(item)); + } + } + std::ranges::sort(file.items, LessByKey); + return file; +} + +void ReadItemExchangeJson(std::string_view text, const std::string& source, ItemExchangeFile& file, + std::vector& issues) +{ + OrderedJson root; + if (!Json::ReadFileVersion(text, source, ItemExchangeFormatVersion, root, issues)) + { + return; + } + ExchangeReader(source, issues).Read(root, file); +} + +std::string WriteItemExchangeJson(const ItemExchangeFile& file) +{ + OrderedJson root; + root[Keys::FormatVersion] = ItemExchangeFormatVersion; + root[Keys::Source] = file.source; + if (!file.server.empty()) + { + root[Keys::Server] = file.server; + } + root[Keys::GameVersion] = file.gameVersion; + if (file.formulas) + { + root[Keys::Formulas] = WriteItemStatFormulasJson(*file.formulas); + } + + std::vector items; + for (const ItemDefinition& item : file.items) + { + items.push_back(&item); + } + std::ranges::sort(items, + [](const ItemDefinition* left, const ItemDefinition* right) { return LessByKey(*left, *right); }); + root[Keys::Items] = OrderedJson::array(); + for (const ItemDefinition* item : items) + { + OrderedJson json; + json[Keys::Group] = item->group; + OrderedJson fields = WriteItemJson(*item); + for (auto& [key, value] : fields.items()) + { + json[key] = std::move(value); + } + root[Keys::Items].push_back(std::move(json)); + } + + // Names are UTF-8 already; anything that is not is replaced, as in the item files. + const std::string text = root.dump(Json::Indent, ' ', false, OrderedJson::error_handler_t::replace); + return LayOutItemStatsText(LayOutItemJsonText(text)) + "\n"; +} + +bool CanImportItemExchange(const ItemExchangeFile& file, std::span issues) +{ + return std::ranges::none_of(issues, + [&](const ItemDataIssue& issue) + { + const std::pair key{issue.group, issue.number}; + return issue.severity == ItemDataIssueSeverity::Error && + std::ranges::find(file.repeatedItems, key) == file.repeatedItems.end(); + }); +} +} // namespace Data::Items + +#endif // _EDITOR diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.h b/src/source/Data/GameData/ItemData/ItemExchangeFile.h new file mode 100644 index 0000000000..0cf17fc2d0 --- /dev/null +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.h @@ -0,0 +1,92 @@ +#pragma once + +#ifdef _EDITOR + +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemDefinition.h" +#include "Data/GameData/ItemData/ItemStatFile.h" + +#include +#include +#include +#include +#include +#include +#include + +// The item exchange file with OpenMU (phase 7, the items design's section 6): the item data +// both sides have and the item formulas, one JSON file in the layout of the item files. +// +// { +// "formatVersion": 1, +// "source": "MuMain", +// "gameVersion": "season6", +// "formulas": { "dropLevelBonus": ..., "tables": [...], "default": {...}, "profiles": [...] }, +// "items": [ +// {"group": 0, "number": 0, "name": {"en": "Kris", ...}, "width": 1, ..., "statProfile": "weapon"} +// ] +// } +// +// The items have the fields of the item files that OpenMU has too, in their names and +// meanings, and their group; a file from OpenMU also names the server ("server"). The formulas +// are the item stat file without its profile rules, which stay with the client. Items are +// written sorted by group and number. +namespace Data::Items +{ +constexpr int ItemExchangeFormatVersion = 1; +constexpr const char* ItemExchangeSourceMuMain = "MuMain"; +constexpr const char* ItemExchangeSourceOpenMU = "OpenMU"; +constexpr const char* ItemExchangeGameVersion = "season6"; + +struct ItemExchangeFile +{ + std::string source = ItemExchangeSourceMuMain; + // Only in a file from OpenMU: the server's installation. + std::string server; + std::string gameVersion = ItemExchangeGameVersion; + // Without profile rules. Nothing when the file has no formulas. + std::optional formulas; + // The shared fields only (ToExchangeItem). + std::vector items; + // Read from a file: the (group, number) pairs it has more than once (OpenMU does not keep + // them unique). None of those items is in `items`. + std::vector> repeatedItems; +}; + +// The tags the exchange file has, as OpenMU has them too (IsAmmunition, IsQuestItem); the client's +// other tags stay with it. +inline constexpr ItemTag ExchangeTags[] = {ItemTag::Ammunition, ItemTag::SecondClassQuestItem, + ItemTag::ThirdClassQuestItem}; + +// Copies the fields the exchange file has from `from` to `to`, all but the group, the number and +// the stat profile (each direction treats those itself); the other fields of `to` stay. The one +// list of them, for the export (ToExchangeItem) and the import (ToClientItem). +void CopyExchangeFields(const ItemDefinition& from, ItemDefinition& to); + +// The file's items by (group, number), each once. +using ExchangeItemIndex = std::map, const ItemDefinition*>; +ExchangeItemIndex IndexExchangeItems(const ItemExchangeFile& file); + +// The item as the exchange file has it: the shared fields, the client-only ones at their +// defaults. `statProfile` names the profile the item uses ("default" for the default). +ItemDefinition ToExchangeItem(const ItemDefinition& item); + +// The exchange file of the client's items and formulas. +ItemExchangeFile BuildItemExchange(std::span items, const ItemStatFile& formulas); + +// Reads the file into `file` and adds its problems to `issues`; with errors it must not be +// used, except for an item that is there twice: an error of that item, which then is in +// `repeatedItems` and not read, while the rest of the file can be used. Fields +// the exchange file has not got are left out with a warning. `source` names the file in +// messages. +void ReadItemExchangeJson(std::string_view text, const std::string& source, ItemExchangeFile& file, + std::vector& issues); + +// Whether a file read with `issues` can be imported: its only errors are those of its repeated +// items. +bool CanImportItemExchange(const ItemExchangeFile& file, std::span issues); + +std::string WriteItemExchangeJson(const ItemExchangeFile& file); +} // namespace Data::Items + +#endif // _EDITOR diff --git a/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp b/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp index f1806b571c..5a285be47e 100644 --- a/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp +++ b/src/source/Data/GameData/ItemData/ItemJsonFormat.cpp @@ -171,7 +171,7 @@ OrderedJson WriteDescription(const std::vector& description return json; } -OrderedJson WriteItem(const ItemDefinition& definition) +OrderedJson WriteItemObject(const ItemDefinition& definition) { OrderedJson item; item[Keys::Number] = definition.number; @@ -650,14 +650,30 @@ std::string WriteItemGroupJson(int group, std::span items) root[Keys::Items] = OrderedJson::array(); for (const ItemDefinition* definition : groupItems) { - root[Keys::Items].push_back(WriteItem(*definition)); + root[Keys::Items].push_back(WriteItemObject(*definition)); } // Names are UTF-8 already; replace (instead of throwing on) anything that // is not, so a bad name can never stop a save half-way. - std::string text = - Json::PutListsOnOneLine(root.dump(Json::Indent, ' ', false, OrderedJson::error_handler_t::replace), Keys::Tags); - text = Json::PutListsOnOneLine(text, Keys::Levels); - return Json::PutObjectsOnOneLine(text, Keys::RequirementsByLevel) + "\n"; + return LayOutItemJsonText(root.dump(Json::Indent, ' ', false, OrderedJson::error_handler_t::replace)) + "\n"; +} + +OrderedJson WriteItemJson(const ItemDefinition& definition) +{ + return WriteItemObject(definition); +} + +bool ReadItemJson(const OrderedJson& json, const std::string& source, int group, ItemDefinition& definition, + std::vector& issues) +{ + ItemReader reader(source, group, issues); + return reader.Read(json, definition); +} + +std::string LayOutItemJsonText(const std::string& text) +{ + std::string laidOut = Json::PutListsOnOneLine(text, Keys::Tags); + laidOut = Json::PutListsOnOneLine(laidOut, Keys::Levels); + return Json::PutObjectsOnOneLine(laidOut, Keys::RequirementsByLevel); } } // namespace Data::Items diff --git a/src/source/Data/GameData/ItemData/ItemJsonFormat.h b/src/source/Data/GameData/ItemData/ItemJsonFormat.h index 570a79edf5..85aa9d7171 100644 --- a/src/source/Data/GameData/ItemData/ItemJsonFormat.h +++ b/src/source/Data/GameData/ItemData/ItemJsonFormat.h @@ -2,6 +2,7 @@ #include "Data/GameData/ItemData/ItemDataIssue.h" #include "Data/GameData/ItemData/ItemDefinition.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" #include #include @@ -36,4 +37,14 @@ void ReadItemGroupJson(std::string_view text, const std::string& source, std::ve // Writes the items of `group` (other items are ignored). std::string WriteItemGroupJson(int group, std::span items); + +// One item as the item files have it, without its group, which the file has. The item +// exchange file with OpenMU (ItemExchangeFile) has its items so too. +Json::OrderedJson WriteItemJson(const ItemDefinition& definition); +// Reads what WriteItemJson writes into `definition`, an item of `group`; false with errors. +// The problems are added to `issues`. +bool ReadItemJson(const Json::OrderedJson& json, const std::string& source, int group, ItemDefinition& definition, + std::vector& issues); +// The layout of the item files (some lists and objects on one line) for a text with items. +std::string LayOutItemJsonText(const std::string& text); } // namespace Data::Items diff --git a/src/source/Data/GameData/ItemData/ItemStatsJson.cpp b/src/source/Data/GameData/ItemData/ItemStatsJson.cpp index a249d95009..d71020fefa 100644 --- a/src/source/Data/GameData/ItemData/ItemStatsJson.cpp +++ b/src/source/Data/GameData/ItemData/ItemStatsJson.cpp @@ -927,6 +927,22 @@ std::string WriteItemStatsJson(const ItemStatFile& file) { OrderedJson root; root[Keys::FormatVersion] = ItemStatsJsonFormatVersion; + OrderedJson formulas = WriteItemStatFormulasJson(file); + for (auto& [key, value] : formulas.items()) + { + root[key] = std::move(value); + } + root[Keys::ProfileRules] = OrderedJson::array(); + for (const StatProfileRule& rule : file.profileRules) + { + root[Keys::ProfileRules].push_back(WriteProfileRule(rule)); + } + return LayOutItemStatsText(root.dump(Json::Indent, ' ', false, OrderedJson::error_handler_t::replace)) + "\n"; +} + +OrderedJson WriteItemStatFormulasJson(const ItemStatFile& file) +{ + OrderedJson root; root[Keys::DropLevelBonus][Keys::Excellent] = file.excellentDropLevelBonus; root[Keys::DropLevelBonus][Keys::Ancient] = file.ancientDropLevelBonus; @@ -963,14 +979,28 @@ std::string WriteItemStatsJson(const ItemStatFile& file) } root[Keys::Profiles].push_back(std::move(json)); } + return root; +} - root[Keys::ProfileRules] = OrderedJson::array(); - for (const StatProfileRule& rule : file.profileRules) +void ReadItemStatFormulasJson(const OrderedJson& json, const std::string& source, ItemStatFile& file, + std::vector& issues) +{ + if (!json.is_object()) { - root[Keys::ProfileRules].push_back(WriteProfileRule(rule)); + Json::AddFileIssue(issues, source, ItemDataIssue::NoItem, "", "the formulas must be an object"); + return; + } + ItemStatsReader(source, issues).Read(json, file); + if (json.contains(Keys::ProfileRules)) + { + issues.push_back({ItemDataIssueSeverity::Warning, source, ItemDataIssue::NoItem, ItemDataIssue::NoItem, + Keys::ProfileRules, "the profile rules stay with the client, ignored"}); + file.profileRules.clear(); } +} - std::string text = root.dump(Json::Indent, ' ', false, OrderedJson::error_handler_t::replace); +std::string LayOutItemStatsText(std::string text) +{ text = Json::PutListEntriesOnOneLine(text, Keys::ProfileRules); text = Json::PutObjectsOnOneLine(text, Keys::DropLevelBonus); text = Json::PutObjectsOnOneLine(text, Keys::Caption); @@ -983,6 +1013,6 @@ std::string WriteItemStatsJson(const ItemStatFile& file) text = Json::PutObjectsOnOneLine(text, requirementKey); } text = Json::PutListsOnOneLine(text, Keys::Values); - return text + "\n"; + return text; } } // namespace Data::Items diff --git a/src/source/Data/GameData/ItemData/ItemStatsJson.h b/src/source/Data/GameData/ItemData/ItemStatsJson.h index 8f06c9faff..bd5dfb4e0d 100644 --- a/src/source/Data/GameData/ItemData/ItemStatsJson.h +++ b/src/source/Data/GameData/ItemData/ItemStatsJson.h @@ -1,6 +1,7 @@ #pragma once #include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" #include "Data/GameData/ItemData/ItemStatFile.h" #include @@ -68,4 +69,15 @@ void ReadItemStatsJson(std::string_view text, const std::string& source, ItemSta std::vector& issues); std::string WriteItemStatsJson(const ItemStatFile& file); + +// The file without its format version and profile rules, as one object: the formulas of the +// item exchange file with OpenMU (ItemExchangeFile), in the layout of the file. +Json::OrderedJson WriteItemStatFormulasJson(const ItemStatFile& file); +// Reads what WriteItemStatFormulasJson writes; profile rules in it are left out with a warning, +// as they stay with the client. +void ReadItemStatFormulasJson(const Json::OrderedJson& json, const std::string& source, ItemStatFile& file, + std::vector& issues); +// The layout of the item stat file (some lists and objects on one line) for a text with its +// objects. +std::string LayOutItemStatsText(std::string text); } // namespace Data::Items diff --git a/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 485340ed4f..5ecd51bd6b 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -110,10 +110,12 @@ mu_add_test( test_dark_lord_pet_tooltip.cpp test_item_database_files.cpp test_item_descriptions.cpp + test_item_exchange_file.cpp test_item_model_files.cpp test_item_requirement_weights.cpp test_item_rules.cpp test_item_stats.cpp + test_item_sync_diff.cpp test_item_tooltips.cpp test_item_value_formulas.cpp ${CMAKE_SOURCE_DIR}/src/source/App/Platform/Windows/Winmain.cpp diff --git a/tests/data/LevelBasisScrollProfile.h b/tests/data/LevelBasisScrollProfile.h new file mode 100644 index 0000000000..2381436259 --- /dev/null +++ b/tests/data/LevelBasisScrollProfile.h @@ -0,0 +1,55 @@ +#pragma once + +#include "ItemStatProfilesScope.h" + +#include "Data/DataHandler/ItemData/ItemJsonStorage.h" +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemStatFile.h" +#include "Data/GameData/ItemData/ItemStatProfiles.h" + +#include "doctest.h" + +#include +#include + +// The shipped stat profiles with the scroll profile's energy as it was before the scrolls kept +// their requirements as values: from the level requirement (the "levelRequirement" basis), and +// from the drop level where an item has none. No shipped profile uses that basis any more, so +// its tests install this one for their scope. A scroll's energy is then a weight again: the test +// gives the item one. +class LevelBasisScrollProfile +{ +public: + explicit LevelBasisScrollProfile(const std::filesystem::path& clientDirectory) + { + Data::Items::ItemStatsLoadResult stats = + Data::Items::LoadItemStatsFile(clientDirectory / Data::Items::GetItemStatsFile()); + REQUIRE_FALSE(Data::Items::HasErrors(stats.issues)); + m_file = std::move(stats.file); + Data::Items::WrittenStatProfile* scroll = nullptr; + for (Data::Items::WrittenStatProfile& profile : m_file.profiles) + { + if (profile.name == "scroll") + { + scroll = &profile; + } + } + REQUIRE(scroll != nullptr); + scroll->requirements.stats[static_cast(Data::Items::ItemRequirementStat::Energy)] = + Data::Items::RequirementFormula{false, 20, 3, 4, true}; + g_ItemDatabase.SetStatProfiles(Data::Items::ItemStatProfiles(m_file)); + } + + // The stat file with that scroll profile, for what reads the file itself (the item tools' + // formula lines). + const Data::Items::ItemStatFile& GetFile() const + { + return m_file; + } + +private: + // Before the profiles are set, so they are put back after. + ItemStatProfilesScope m_restore; + Data::Items::ItemStatFile m_file; +}; diff --git a/tests/data/item_tooltip_snapshot.txt b/tests/data/item_tooltip_snapshot.txt index 73239ea130..1a24e8e040 100644 --- a/tests/data/item_tooltip_snapshot.txt +++ b/tests/data/item_tooltip_snapshot.txt @@ -174,7 +174,7 @@ bladeMaster 11/8 32341f1a2318bd2b bladeMaster 11/9 925950a727cf394b bladeMaster 12/0 e1cb3a597d686af3 bladeMaster 12/1 627698e957f4f6cb -bladeMaster 12/10 39e955aa138b7bb6 +bladeMaster 12/10 7b75471e4872058c bladeMaster 12/100 ae62d6000634c075 bladeMaster 12/101 a3942cd987ecf5cd bladeMaster 12/102 536358fa592abe96 @@ -224,15 +224,15 @@ bladeMaster 12/141 71bd4a9693b4743b bladeMaster 12/142 db3d5f16ecd6106d bladeMaster 12/143 bd50c82c47c3f90f bladeMaster 12/15 9f17b903a6b152da -bladeMaster 12/16 a2f85645a50b0561 +bladeMaster 12/16 2aab3278f668e678 bladeMaster 12/17 a0b2330b7f01d498 -bladeMaster 12/18 79dc35f7a8c44ed1 +bladeMaster 12/18 c8520fbc38302fdc bladeMaster 12/19 fc11f5f482e351a0 bladeMaster 12/2 8bed54088a34388a -bladeMaster 12/21 9c04a5b718f7aa59 -bladeMaster 12/22 108d0f43f8daa81a -bladeMaster 12/23 c6d4d748569b07de -bladeMaster 12/24 47486a9926edc703 +bladeMaster 12/21 d7e17fececd35432 +bladeMaster 12/22 d3bc159fcb16e0e4 +bladeMaster 12/23 3d62e2c0eb330186 +bladeMaster 12/24 9a9ce10038ad362e bladeMaster 12/26 3dbebac776e7282c bladeMaster 12/3 d05e6f9d93096b75 bladeMaster 12/30 28d8e7be83e38a13 @@ -240,7 +240,7 @@ bladeMaster 12/31 ba9aaa6c7dbe1e8d bladeMaster 12/32 1d33da03157ba3c7 bladeMaster 12/33 1c7d9406dcedfd2d bladeMaster 12/34 e07dc42b74c60399 -bladeMaster 12/35 5f47fe682b950268 +bladeMaster 12/35 9932d88310c79c79 bladeMaster 12/36 b98f9390525f0c6f bladeMaster 12/37 6328a9dd0c7a40fe bladeMaster 12/38 e2797ce3073b9cf6 @@ -252,9 +252,9 @@ bladeMaster 12/42 37bcfbf7d262548b bladeMaster 12/43 0ce416193dd40502 bladeMaster 12/44 76a79bc88f72a498 bladeMaster 12/45 94cdf9cb8064c8cc -bladeMaster 12/46 9f54a52476fe9ac6 +bladeMaster 12/46 212c5d625a611670 bladeMaster 12/47 675297c68491fbba -bladeMaster 12/48 d44fd22b6072a94a +bladeMaster 12/48 470bbbdeb082ee70 bladeMaster 12/49 0db990e463e181a8 bladeMaster 12/5 5f1db4382bb9416a bladeMaster 12/50 36ef3e95b7c787de @@ -269,8 +269,8 @@ bladeMaster 12/7 e9015f57bb9ca098 bladeMaster 12/70 9abc8b28e2118687 bladeMaster 12/71 4f3fecdf9a420c53 bladeMaster 12/72 a8ec2fd04ef9daa1 -bladeMaster 12/8 77f380a453515fe9 -bladeMaster 12/9 2a761de7128ee9fe +bladeMaster 12/8 a47a094a63a16ca4 +bladeMaster 12/9 c4d53a82883ab646 bladeMaster 13/0 96ba85685c748d01 bladeMaster 13/1 b05512bb52a88e58 bladeMaster 13/10 4da2c5f4ee131c41 @@ -570,29 +570,29 @@ bladeMaster 14/96 93c2c35209818d18 bladeMaster 14/97 b05e59857d5fa1c6 bladeMaster 14/98 fb492b8aaa684a02 bladeMaster 14/99 6381c7b1c3e0eb5e -bladeMaster 15/0 d48422cab514befe -bladeMaster 15/1 10ca0c0b9521bc08 -bladeMaster 15/10 9e3793fa9e94c6be -bladeMaster 15/11 4d12c318515e7bfc -bladeMaster 15/12 2051bd38d4de8c72 -bladeMaster 15/13 09d7205aea786fbc -bladeMaster 15/14 c2d13abfc7d6fec2 -bladeMaster 15/15 e96b22d950fa2a10 -bladeMaster 15/16 0999ecf9a8835ad8 -bladeMaster 15/17 32f5807159d5b315 -bladeMaster 15/18 0742da6acb89aa72 -bladeMaster 15/19 c7f35c57e34eb316 -bladeMaster 15/2 aafeeb08f3095512 -bladeMaster 15/20 32b6b8cc16f19c08 -bladeMaster 15/21 3379cc297d4b25e5 -bladeMaster 15/22 11e5da235791330b -bladeMaster 15/23 789ec9d381a1de80 -bladeMaster 15/24 cb6fc2f1e47225e6 -bladeMaster 15/26 12b58245e86e7ab1 -bladeMaster 15/27 4022a49ddbd52bb0 +bladeMaster 15/0 8dd8e922d0389ce6 +bladeMaster 15/1 7e8d531c24166866 +bladeMaster 15/10 fea554b5a52c9b16 +bladeMaster 15/11 2b343990a8c5354c +bladeMaster 15/12 58ab320c3451f8fe +bladeMaster 15/13 5779e9d09b8c2604 +bladeMaster 15/14 027ed30098df1d06 +bladeMaster 15/15 5cc5303704921488 +bladeMaster 15/16 79e51e1c6da261a4 +bladeMaster 15/17 2d1544ec8a1f139e +bladeMaster 15/18 e025699a3fc05356 +bladeMaster 15/19 79c2a9fd67332e7c +bladeMaster 15/2 441e7c37e3e83cba +bladeMaster 15/20 27f3d5c38f71cd4c +bladeMaster 15/21 ead1248c35ffdbec +bladeMaster 15/22 234b20dcb573aed1 +bladeMaster 15/23 39cf5f9377c3672a +bladeMaster 15/24 b3b738eb5d1eb05c +bladeMaster 15/26 28397efbd670da52 +bladeMaster 15/27 a0cce4333f3a88e7 bladeMaster 15/28 a7034cb5e270ebfe bladeMaster 15/29 ed097a39c82ce65c -bladeMaster 15/3 8d32f88eee1ad5b5 +bladeMaster 15/3 93cb1ee8dc78e348 bladeMaster 15/30 f9ef5e06fcdebb4d bladeMaster 15/31 922301045808841f bladeMaster 15/32 198b00e668d624d1 @@ -600,12 +600,12 @@ bladeMaster 15/33 354805078fc15009 bladeMaster 15/34 66d49cb7da921d03 bladeMaster 15/35 a6ce6b3544042ce2 bladeMaster 15/36 24d203d5fd79d687 -bladeMaster 15/4 57f7733f7d8477bf -bladeMaster 15/5 c227452abb5e72c1 -bladeMaster 15/6 88ed21c7add135f7 -bladeMaster 15/7 3a2b0a5032dad118 -bladeMaster 15/8 00d2eeb4bb3a5ef8 -bladeMaster 15/9 02b6e5c337f13f46 +bladeMaster 15/4 90be3ec3feb40396 +bladeMaster 15/5 a2be2638d4d846da +bladeMaster 15/6 f74271fada9a7eee +bladeMaster 15/7 922abbdfd4469502 +bladeMaster 15/8 e2f2dfb62d119afe +bladeMaster 15/9 30b2554574fd9e26 bladeMaster 2/0 09ef6ed6f663696e bladeMaster 2/1 606f1332cf903aa6 bladeMaster 2/10 8bf0317d549585a8 @@ -1085,7 +1085,7 @@ newDarkWizard 11/8 7b3a98064cd745e8 newDarkWizard 11/9 19d8d277b562aac1 newDarkWizard 12/0 d849ce70ed01775f newDarkWizard 12/1 925a197dd0de4281 -newDarkWizard 12/10 8530395130678ee8 +newDarkWizard 12/10 a695cdfec48f9434 newDarkWizard 12/100 2a62c13a3ee8b327 newDarkWizard 12/101 1b0b4020518ce5bd newDarkWizard 12/102 754d81a7c51a2aae @@ -1135,15 +1135,15 @@ newDarkWizard 12/141 71bd4a9693b4743b newDarkWizard 12/142 db3d5f16ecd6106d newDarkWizard 12/143 bd50c82c47c3f90f newDarkWizard 12/15 9f17b903a6b152da -newDarkWizard 12/16 99409e17267de588 +newDarkWizard 12/16 cfb146a163078b30 newDarkWizard 12/17 948952f59bff82f0 -newDarkWizard 12/18 128d23e78dc8b94f +newDarkWizard 12/18 b85106d15a37b52c newDarkWizard 12/19 727c62fa0852ce14 newDarkWizard 12/2 72b3003c91c2fb76 -newDarkWizard 12/21 99dc39533e933446 -newDarkWizard 12/22 12a1e3683ecacc75 -newDarkWizard 12/23 8aeb0dce19497b82 -newDarkWizard 12/24 b14540497762cbf4 +newDarkWizard 12/21 6e91e5760fa46c02 +newDarkWizard 12/22 8d8d905aef8a4688 +newDarkWizard 12/23 abd32a1955b44bba +newDarkWizard 12/24 b3a8cb62dc0b2a42 newDarkWizard 12/26 3dbebac776e7282c newDarkWizard 12/3 c3a0bcdb874f3861 newDarkWizard 12/30 28d8e7be83e38a13 @@ -1151,7 +1151,7 @@ newDarkWizard 12/31 ba9aaa6c7dbe1e8d newDarkWizard 12/32 1d33da03157ba3c7 newDarkWizard 12/33 1c7d9406dcedfd2d newDarkWizard 12/34 e07dc42b74c60399 -newDarkWizard 12/35 2d5a7ecea9871237 +newDarkWizard 12/35 39d8fcefabb8587b newDarkWizard 12/36 c428daeb643144dd newDarkWizard 12/37 252cf370defc1638 newDarkWizard 12/38 d9e14c28025815ec @@ -1163,9 +1163,9 @@ newDarkWizard 12/42 84a86b292e36adbf newDarkWizard 12/43 40c283df61a9e268 newDarkWizard 12/44 16e3920902de0d00 newDarkWizard 12/45 0816469e6a82a804 -newDarkWizard 12/46 b3d611597ec706fc +newDarkWizard 12/46 cb44fdb3e8bfb650 newDarkWizard 12/47 68e6b659a948e542 -newDarkWizard 12/48 341b7b0b92f6bafc +newDarkWizard 12/48 1cf29595bc4b5878 newDarkWizard 12/49 38a5cef352a28e92 newDarkWizard 12/5 1291ab2cb1dc38ee newDarkWizard 12/50 9c8de5fe7a62bedc @@ -1180,8 +1180,8 @@ newDarkWizard 12/7 afeff7577349b42c newDarkWizard 12/70 9abc8b28e2118687 newDarkWizard 12/71 4f3fecdf9a420c53 newDarkWizard 12/72 a8ec2fd04ef9daa1 -newDarkWizard 12/8 ace65e3a5a4c1e0b -newDarkWizard 12/9 22ac1bfdcc0afbf6 +newDarkWizard 12/8 df46cc1814c2124c +newDarkWizard 12/9 d25584067d75e12e newDarkWizard 13/0 ccca2cc2633f713a newDarkWizard 13/1 2a92593436379f23 newDarkWizard 13/10 5ba9a103ee9edb32 @@ -1481,29 +1481,29 @@ newDarkWizard 14/96 93c2c35209818d18 newDarkWizard 14/97 b05e59857d5fa1c6 newDarkWizard 14/98 fb492b8aaa684a02 newDarkWizard 14/99 6381c7b1c3e0eb5e -newDarkWizard 15/0 706572aef716390d -newDarkWizard 15/1 05a92bb5e759653c -newDarkWizard 15/10 eb43ff40e6ad2715 -newDarkWizard 15/11 df0e5e678eb3c0d2 -newDarkWizard 15/12 9ee276ab19c1e394 -newDarkWizard 15/13 ca61751c0fd6363f -newDarkWizard 15/14 13dac6ac693144ff -newDarkWizard 15/15 20602db4b62be2e2 -newDarkWizard 15/16 b81ad3eb86e32802 -newDarkWizard 15/17 2dddb6e69aedb4e1 -newDarkWizard 15/18 41fb71de21a8da8d -newDarkWizard 15/19 bd60103a930c1687 -newDarkWizard 15/2 650cee147a309b32 -newDarkWizard 15/20 a70326e1920e4b60 -newDarkWizard 15/21 5576e21d8b5a6e98 -newDarkWizard 15/22 45b70e5a59148aeb -newDarkWizard 15/23 cdc2eb36ccd8b07d -newDarkWizard 15/24 28eb23b2426258ed -newDarkWizard 15/26 32f4491005e72c5c -newDarkWizard 15/27 e8a93f9c19c8b560 +newDarkWizard 15/0 82c6b498e05c7a32 +newDarkWizard 15/1 134c5f2dbc8706ca +newDarkWizard 15/10 8949c292b7449d66 +newDarkWizard 15/11 ba5bc4564cbc2e60 +newDarkWizard 15/12 449d5f2ce98a28ae +newDarkWizard 15/13 2d761e552270e31c +newDarkWizard 15/14 e1ef8a092110c8ee +newDarkWizard 15/15 2bbcdb45166cbdd8 +newDarkWizard 15/16 2abc8b86e1faaec4 +newDarkWizard 15/17 53244cab632ad016 +newDarkWizard 15/18 655c13d77caa5986 +newDarkWizard 15/19 ed59a65a98c9b4f4 +newDarkWizard 15/2 cbf7a2c89f2744b2 +newDarkWizard 15/20 fc61bba06ccf0838 +newDarkWizard 15/21 97d57b0d9967dfac +newDarkWizard 15/22 e0650a6b1cfbf3b1 +newDarkWizard 15/23 99b654b35875c51e +newDarkWizard 15/24 d08c0ccde97e3d8c +newDarkWizard 15/26 4b37738a09808eda +newDarkWizard 15/27 bc2c3d491e219373 newDarkWizard 15/28 55332d80f21cb514 newDarkWizard 15/29 52319041eed334d0 -newDarkWizard 15/3 779cc7516adabdaf +newDarkWizard 15/3 46755db98293d478 newDarkWizard 15/30 423b642aa6fb4548 newDarkWizard 15/31 2c7f6c1095c0797f newDarkWizard 15/32 dda93060e857e5b0 @@ -1511,12 +1511,12 @@ newDarkWizard 15/33 bfba07d449f411e4 newDarkWizard 15/34 1ffd5d3511642ea7 newDarkWizard 15/35 819e9a0354f66634 newDarkWizard 15/36 06896c04ce989570 -newDarkWizard 15/4 5ac2d14c0e0c3bd4 -newDarkWizard 15/5 e587f708058e28db -newDarkWizard 15/6 881b0c427b04f2db -newDarkWizard 15/7 f51bac42faea5ef3 -newDarkWizard 15/8 eb4d6387cd64d7e3 -newDarkWizard 15/9 420101650a435ff8 +newDarkWizard 15/4 476e13ccb051d7b2 +newDarkWizard 15/5 72d235220d1bb4c2 +newDarkWizard 15/6 7e1fb883858ec45e +newDarkWizard 15/7 3ac3592969db76a6 +newDarkWizard 15/8 036f11def0a95ed2 +newDarkWizard 15/9 44576a1473a0ddca newDarkWizard 2/0 d656121e9ea014c5 newDarkWizard 2/1 9001e201139da1c4 newDarkWizard 2/10 00493c8e7a375a98 diff --git a/tests/data/test_item_database_files.cpp b/tests/data/test_item_database_files.cpp index 741a53eb25..1d2d9b32d3 100644 --- a/tests/data/test_item_database_files.cpp +++ b/tests/data/test_item_database_files.cpp @@ -12,6 +12,7 @@ #include "Core/Utilities/Log/MuLogger.h" #include "Data/DataHandler/ItemData/ItemBmdImport.h" #include "Data/DataHandler/ItemData/ItemDataHandler.h" +#include "Data/DataHandler/ItemData/ItemDataLoader.h" #include "Data/DataHandler/ItemData/ItemJsonStorage.h" #include "Data/GameData/ItemData/ItemAttributeConversion.h" #include "Data/GameData/ItemData/ItemDataValidation.h" @@ -62,6 +63,7 @@ #include "UI/ItemTools/MuItemModelUI.h" #include "UI/ItemTools/MuItemRequirementsUI.h" #include "UI/ItemTools/MuItemRulesUI.h" +#include "UI/ItemTools/MuItemSyncUI.h" #include "UI/ItemTranslations/MuItemTranslationsUI.h" #include "UI/MapEditor/MapEditorUI.h" #include "TestModelSlots.h" @@ -1073,6 +1075,66 @@ TEST_CASE("Bmd import recovers long names and repairs the fields they overwrote CHECK(result.repairs.front().fromLocale == "pt"); } +// The orbs, crystals and scrolls keep their requirements as values (their profiles keep the base +// value); the original client's bmd files hold weights for them, which its code grew. +TEST_CASE("The orbs, crystals and scrolls hold their requirements as weights in a bmd file [data][items]") +{ + const ItemStatsLoadResult stats = LoadItemStatsFile(ShippedClientDirectory / GetItemStatsFile()); + REQUIRE(stats.issues.empty()); + const ItemStatProfiles profiles(stats.file); + + // Healing Orb: 20 + weight * drop level * 4 / 100. + ItemDefinition orb; + orb.group = 12; + orb.number = 8; + orb.level = 8; + orb.statProfile = "orb"; + orb.requirements.energy = 100; + REQUIRE(HasRequirementWeightsInBmd(profiles, orb)); + ToRequirementValues(profiles, orb); + CHECK(orb.requirements.energy == 52); + ITEM_ATTRIBUTE attribute{}; + CHECK(ToBmdRequirementWeights(profiles, orb, attribute).empty()); + CHECK(attribute.RequireEnergy == 100); + // Without a profile it names, the rules give it the orb profile. + ItemDefinition ruledOrb = orb; + ruledOrb.statProfile.clear(); + CHECK(HasRequirementWeightsInBmd(profiles, ruledOrb)); + // Given the default profile, its requirements grow like any item's: weights. + ItemDefinition defaultOrb = orb; + defaultOrb.statProfile = "default"; + CHECK_FALSE(HasRequirementWeightsInBmd(profiles, defaultOrb)); + + // Scroll of Wizardry Enhance: the energy from the level requirement, 20 + weight * 220 * 4 / 100. + ItemDefinition scroll; + scroll.group = 15; + scroll.number = 28; + scroll.level = 100; + scroll.statProfile = "scroll"; + scroll.requirements.level = 220; + scroll.requirements.energy = 118; + ToRequirementValues(profiles, scroll); + CHECK(scroll.requirements.level == 220); + CHECK(scroll.requirements.energy == 1058); + CHECK(ToBmdRequirementWeights(profiles, scroll, attribute).empty()); + CHECK(attribute.RequireEnergy == 118); + + // A value no weight gives (each weight adds 4): the nearest one. + ItemDefinition steps = orb; + steps.level = 100; + steps.requirements.energy = 27; + const std::vector inexact = ToBmdRequirementWeights(profiles, steps, attribute); + CHECK(inexact == std::vector{ItemRequirement::Energy}); + CHECK(attribute.RequireEnergy == 2); + + // A weapon keeps its weights in both. + ItemDefinition kris; + kris.group = 0; + kris.slot = ItemSlot::MainHand; + kris.statProfile = "weapon"; + CHECK_FALSE(HasRequirementWeightsInBmd(profiles, kris)); +} + TEST_CASE("Bmd import reads Portuguese and Spanish names as Windows-1252 [data][items]") { TemporaryClientFolder folder; @@ -1805,6 +1867,52 @@ TEST_CASE("A bmd import moves the models of moved items back [data][items][edito // The names of a language whose item.bmd is missing stay as they are, like // the names of the languages that have none (German). +// Export as bmd writes the weights the original client grows into the requirements the client +// keeps, and Import from bmd takes them back. +TEST_CASE("Export as bmd and Import from bmd keep the orbs', crystals' and scrolls' requirements [data][items][editor]") +{ + TemporaryClientFolder folder; + folder.CopyShippedItems(); + folder.CopyShippedGlowColors(); + ClientDataScope client(folder.Directory()); + ItemModelDatabaseScope restoreModels; + I18N::SetLocale("en"); + std::string errorMessage; + REQUIRE(g_ItemDataHandler.Load(errorMessage)); + REQUIRE(g_ItemDataHandler.LoadModels(errorMessage)); + std::vector weighted; + for (const ItemDefinition& item : g_ItemDatabase.GetAllSlots()) + { + if (item.Exists() && HasRequirementWeightsInBmd(g_ItemDatabase.GetStatProfiles(), item) && + (item.requirements.strength > 0 || item.requirements.dexterity > 0 || item.requirements.energy > 0 || + item.requirements.vitality > 0)) + { + weighted.push_back(item); + } + } + REQUIRE(weighted.size() == 44); + + std::string changeLog; + REQUIRE(g_ItemDataHandler.ExportAsBmd(changeLog)); + CHECK(changeLog.find("no weight gives") == std::string::npos); + // The file holds the weight: the Healing Orb's energy of 52 as 100. + ItemDataLoader::RawItemFile raw; + REQUIRE(ItemDataLoader::ReadRawFile(CItemDataHandler::GetItemFilePath(L"Eng").c_str(), raw)); + REQUIRE_FALSE(raw.isLegacyFormat); + REQUIRE(raw.recordSize == static_cast(sizeof(ITEM_ATTRIBUTE_FILE))); + ITEM_ATTRIBUTE_FILE healingOrb; + std::memcpy(&healingOrb, raw.GetRecord(MakeItemType(12, 8)), sizeof(healingOrb)); + CHECK(healingOrb.RequireEnergy == 100); + REQUIRE_FALSE(HasErrors(g_ItemDataHandler.ImportFromBmd().issues)); + for (const ItemDefinition& item : weighted) + { + CAPTURE(g_ItemDatabase.GetLogName(MakeItemType(item.group, item.number))); + const ItemDefinition* imported = g_ItemDatabase.Find(item.group, item.number); + REQUIRE(imported != nullptr); + CHECK(imported->requirements == item.requirements); + } +} + TEST_CASE("A bmd import keeps the names of a language whose item.bmd is missing [data][items][editor]") { TemporaryClientFolder folder; @@ -2327,6 +2435,7 @@ void RenderItemTools() g_MuItemCategoriesUI.Render(&open); g_MuItemModelUI.Render(&open); g_MuItemGlowColorsUI.Render(&open); + g_MuItemSyncUI.Render(&open); g_MuItemFormulasUI.Render(&open); g_MuItemChecksUI.Render(&open); g_MuItemTranslationsUI.Render(&open); @@ -3729,7 +3838,8 @@ TEST_CASE("The bmd exports refuse damage or magic power past 255 and name the it std::wstring legacyFile = (folder.Directory() / "Item_S6E3.bmd").wstring(); std::string problems; - CHECK_FALSE(g_ItemDataHandler.ExportAsS6E3(legacyFile.data(), problems)); + std::string notes; + CHECK_FALSE(g_ItemDataHandler.ExportAsS6E3(legacyFile.data(), problems, notes)); CHECK(problems == named); CHECK_FALSE(std::filesystem::exists(legacyFile)); @@ -3739,7 +3849,7 @@ TEST_CASE("The bmd exports refuse damage or magic power past 255 and name the it std::string savedChangeLog; CHECK(g_ItemDataHandler.ExportAsBmd(savedChangeLog)); problems.clear(); - CHECK(g_ItemDataHandler.ExportAsS6E3(legacyFile.data(), problems)); + CHECK(g_ItemDataHandler.ExportAsS6E3(legacyFile.data(), problems, notes)); CHECK(problems.empty()); CHECK(std::filesystem::exists(legacyFile)); } diff --git a/tests/data/test_item_exchange_file.cpp b/tests/data/test_item_exchange_file.cpp new file mode 100644 index 0000000000..a5f142fcbd --- /dev/null +++ b/tests/data/test_item_exchange_file.cpp @@ -0,0 +1,228 @@ +#include "stdafx.h" + +#include "doctest.h" + +#ifdef _EDITOR + +#include "ClientDataScope.h" + +#include "Data/DataHandler/ItemData/ItemDataHandler.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemExchangeFile.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" + +#include +#include +#include + +using namespace Data::Items; + +// The item exchange file with OpenMU (phase 7): the shared fields of the items and the item +// formulas, in the layout of the item files. +namespace +{ +const std::filesystem::path ClientDirectory = std::filesystem::path(MU_TEST_DATA_DIR).parent_path(); + +class ShippedItems +{ +public: + ShippedItems() : m_client(ClientDirectory) + { + I18N::SetLocale("en"); + std::string errorMessage; + REQUIRE(g_ItemDataHandler.Load(errorMessage)); + } + +private: + ClientDataScope m_client; +}; + +ItemExchangeFile ReadExchange(const std::string& text, std::vector& issues) +{ + ItemExchangeFile file; + ReadItemExchangeJson(text, "exchange.json", file, issues); + return file; +} + +bool HasIssueAbout(const std::vector& issues, ItemDataIssueSeverity severity, const std::string& text) +{ + for (const ItemDataIssue& issue : issues) + { + if (issue.severity == severity && issue.ToString().find(text) != std::string::npos) + { + return true; + } + } + return false; +} + +// A file of one item around `item` (the JSON of one item, with its group). +std::string FileWithItem(const std::string& item, + const std::string& header = R"("source": "OpenMU", "gameVersion": "season6")") +{ + return R"({"formatVersion": 1, )" + header + R"(, "items": [)" + item + "]}"; +} +} // namespace + +TEST_CASE( + "Every shipped item and the shipped formulas go to the exchange file and back unchanged [data][items][editor]") +{ + ShippedItems items; + const ItemExchangeFile built = BuildItemExchange(g_ItemDatabase.GetAllSlots(), g_ItemDataHandler.GetStatFile()); + REQUIRE(built.items.size() == 911); + REQUIRE(built.formulas); + CHECK(built.formulas->profileRules.empty()); + CHECK(built.formulas->profiles == g_ItemDataHandler.GetStatFile().profiles); + + const std::string text = WriteItemExchangeJson(built); + std::vector issues; + const ItemExchangeFile read = ReadExchange(text, issues); + CHECK(issues.empty()); + CHECK(read.source == ItemExchangeSourceMuMain); + CHECK(read.server.empty()); + CHECK(read.gameVersion == ItemExchangeGameVersion); + REQUIRE(read.formulas); + CHECK(*read.formulas == *built.formulas); + REQUIRE(read.items.size() == built.items.size()); + for (size_t index = 0; index < built.items.size(); ++index) + { + CAPTURE(g_ItemDatabase.GetLogName(MakeItemType(built.items[index].group, built.items[index].number))); + CHECK(read.items[index] == built.items[index]); + } + // The same data gives the same text. + CHECK(WriteItemExchangeJson(read) == text); + + // In the layout of the item files and the item stat file. + CHECK(text.find(R"("dropLevelBonus": {"excellent": 25, "ancient": 30})") != std::string::npos); + CHECK(text.find(R"("tags": ["ammunition"])") != std::string::npos); + CHECK(text.find("profileRules") == std::string::npos); +} + +TEST_CASE("The exchange file has only the shared fields of an item [data][items][editor]") +{ + ShippedItems items; + const ItemDefinition kris = *g_ItemDatabase.Find(MakeItemType(0, 0)); + REQUIRE(kris.weaponKind != WeaponKind::None); + const ItemDefinition shared = ToExchangeItem(kris); + CHECK(shared.names == kris.names); + CHECK(shared.requirements == kris.requirements); + CHECK(shared.classRequirements == kris.classRequirements); + CHECK(shared.statProfile == "weapon"); + // Client-only: the weapon kind and the attack type. + CHECK(shared.weaponKind == WeaponKind::None); + CHECK(shared.attackType == 0); + + // The tags OpenMU has too; a profile the item has by default is named. + ItemDefinition arrows = *g_ItemDatabase.Find(MakeItemType(4, 15)); + REQUIRE(arrows.tags.Has(ItemTag::Ammunition)); + arrows.tags.Set(ItemTag::Valuable); + arrows.statProfile.clear(); + const ItemDefinition sharedArrows = ToExchangeItem(arrows); + CHECK(sharedArrows.tags.Has(ItemTag::Ammunition)); + CHECK_FALSE(sharedArrows.tags.Has(ItemTag::Valuable)); + CHECK(sharedArrows.statProfile == "default"); +} + +TEST_CASE("The exchange file reader refuses what cannot be exchanged [data][items][editor]") +{ + const std::string kris = R"({"group": 0, "number": 0, "name": {"en": "Kris"}, "width": 1, "height": 2})"; + std::vector issues; + + SUBCASE("a file of another game version") + { + ReadExchange(FileWithItem(kris, R"("source": "OpenMU", "gameVersion": "season4")"), issues); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Error, "season6")); + } + SUBCASE("an unknown source") + { + ReadExchange(FileWithItem(kris, R"("source": "Other", "gameVersion": "season6")"), issues); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Error, "MuMain")); + } + SUBCASE("an empty server is as if there were none") + { + const ItemExchangeFile file = ReadExchange( + R"({"formatVersion": 1, "source": "OpenMU", "server": "", "gameVersion": "season6", "items": []})", issues); + CHECK_FALSE(HasErrors(issues)); + CHECK(file.server.empty()); + } + SUBCASE("a newer format") + { + ReadExchange(R"({"formatVersion": 2, "source": "OpenMU", "gameVersion": "season6", "items": []})", issues); + CHECK(HasErrors(issues)); + } + SUBCASE("an item without a group") + { + const ItemExchangeFile file = ReadExchange(FileWithItem(R"({"number": 0, "name": "Kris"})"), issues); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Error, "group")); + CHECK(file.items.empty()); + CHECK_FALSE(CanImportItemExchange(file, issues)); + } + SUBCASE("an item that is there twice") + { + const std::string other = R"({"group": 0, "number": 1, "name": "Short Sword"})"; + const ItemExchangeFile file = ReadExchange(FileWithItem(kris + ", " + other + ", " + kris), issues); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Error, "2 times")); + REQUIRE(file.items.size() == 1); + CHECK(file.items[0].number == 1); + CHECK(file.repeatedItems == std::vector>{{0, 0}}); + // The rest of the file can be imported. + CHECK(CanImportItemExchange(file, issues)); + } +} + +TEST_CASE("The exchange file reader leaves out what stays with the client [data][items][editor]") +{ + std::vector issues; + + SUBCASE("a client-only field of an item") + { + const ItemExchangeFile file = ReadExchange( + FileWithItem( + R"({"group": 0, "number": 0, "name": "Kris", "weaponKind": "sword", "statProfile": "weapon"})"), + issues); + CHECK_FALSE(HasErrors(issues)); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Warning, "stay with the client")); + REQUIRE(file.items.size() == 1); + CHECK(file.items[0].weaponKind == WeaponKind::None); + CHECK(file.items[0].statProfile == "weapon"); + } + SUBCASE("an item's description") + { + const ItemExchangeFile file = ReadExchange( + FileWithItem(R"({"group": 0, "number": 0, "name": "Kris", "description": [{"text": "A short dagger"}]})"), + issues); + CHECK_FALSE(HasErrors(issues)); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Warning, "stay with the client")); + REQUIRE(file.items.size() == 1); + CHECK(file.items[0].description.empty()); + } + SUBCASE("profile rules in the formulas") + { + ShippedItems items; + ItemExchangeFile built = BuildItemExchange({}, g_ItemDataHandler.GetStatFile()); + std::string text = WriteItemExchangeJson(built); + const std::string profiles = R"("profiles": [)"; + const size_t at = text.find(profiles); + REQUIRE(at != std::string::npos); + text.insert(at, R"("profileRules": [{"profile": "default", "tags": ["ammunition"]}], )"); + const ItemExchangeFile file = ReadExchange(text, issues); + CHECK_FALSE(HasErrors(issues)); + CHECK(HasIssueAbout(issues, ItemDataIssueSeverity::Warning, "profile rules")); + REQUIRE(file.formulas); + CHECK(file.formulas->profileRules.empty()); + } + SUBCASE("the server of a file from OpenMU") + { + const ItemExchangeFile file = + ReadExchange(FileWithItem(R"({"group": 0, "number": 0, "name": "Kris"})", + R"("source": "OpenMU", "server": "3f2a", "gameVersion": "season6")"), + issues); + CHECK(issues.empty()); + CHECK(file.source == ItemExchangeSourceOpenMU); + CHECK(file.server == "3f2a"); + CHECK_FALSE(file.formulas); + } +} + +#endif // _EDITOR diff --git a/tests/data/test_item_requirement_weights.cpp b/tests/data/test_item_requirement_weights.cpp index 6ce77a564f..20edcfdeed 100644 --- a/tests/data/test_item_requirement_weights.cpp +++ b/tests/data/test_item_requirement_weights.cpp @@ -5,6 +5,7 @@ #ifdef _EDITOR #include "ClientDataScope.h" +#include "LevelBasisScrollProfile.h" #include "UI/ItemTools/ItemRequirementTable.h" @@ -151,8 +152,16 @@ TEST_CASE("Typing a +0 requirement takes its weight, lists several or offers the CHECK(pick.weights == WeightRange{45, 49}); CHECK(pick.weight == 45); - // A scroll's energy takes the level requirement: 20 + weight * 50 * 4 / 100. - const ItemDefinition& parchment = Shipped(IncreaseBlockParchmentType); + // A scroll keeps its energy as stored: it has no weight. + CHECK_FALSE(HasWeight(Shipped(IncreaseBlockParchmentType), ItemRequirement::Energy)); + CHECK(ComputeZeroValue(Shipped(IncreaseBlockParchmentType), ItemRequirement::Energy) == 80); + + // The "levelRequirement" basis, which no shipped profile uses any more: 20 + weight * 50 * 4 + // / 100 for the parchment's level requirement of 50. + const LevelBasisScrollProfile levelBasis(ClientDirectory); + ItemDefinition parchment = Shipped(IncreaseBlockParchmentType); + parchment.requirements.energy = 30; + REQUIRE(HasWeight(parchment, ItemRequirement::Energy)); CHECK(ComputeZeroValue(parchment, ItemRequirement::Energy) == 80); const WeightChoice take = ChooseWeight(parchment, ItemRequirement::Energy, 82, 30); CHECK(take.kind == Kind::Take); diff --git a/tests/data/test_item_stats.cpp b/tests/data/test_item_stats.cpp index f3872f9a81..b8380dc9a4 100644 --- a/tests/data/test_item_stats.cpp +++ b/tests/data/test_item_stats.cpp @@ -4,6 +4,7 @@ #include "ClientDataScope.h" #include "ItemStatProfilesScope.h" +#include "LevelBasisScrollProfile.h" #include "TestFiles.h" #include "Data/DataHandler/ItemData/ItemDataHandler.h" @@ -208,7 +209,7 @@ TEST_CASE("Every shipped item names the stat profile the profile rules give [dat itemsWithProfile += definition.statProfile.empty() || hornOfFenrir ? 0 : 1; } CHECK(DeriveStatProfile(g_ItemDatabase.GetStatProfiles(), *g_ItemDatabase.Find(ITEM_HORN_OF_FENRIR)) == "helper"); - CHECK(itemsWithProfile == 566); + CHECK(itemsWithProfile == 578); } TEST_CASE("The shipped profile rules give each kind of item its profile [data][items]") @@ -285,7 +286,7 @@ TEST_CASE("The items each shipped profile rule gives its profile [data][items]") { ShippedItemAttributes items; const ItemStatProfiles& profiles = g_ItemDatabase.GetStatProfiles(); - const std::vector expected = {2, 10, 3, 22, 113, 327, 6, 4, 2, 5, 7, 0, 36, 32}; + const std::vector expected = {2, 10, 3, 22, 113, 327, 6, 4, 2, 5, 7, 0, 12, 36, 32}; REQUIRE(profiles.GetRules().size() == expected.size()); std::vector counts(expected.size()); int withoutRule = 0; @@ -304,7 +305,7 @@ TEST_CASE("The items each shipped profile rule gives its profile [data][items]") ++counts[static_cast(rule)]; } CHECK(counts == expected); - CHECK(withoutRule == 342); + CHECK(withoutRule == 330); } // The fixed excellent bonus of the three chaos weapons is item data. @@ -405,21 +406,26 @@ TEST_CASE("Item requirements come from the item's stat profile [data][items]") CHECK(stats(ITEM_BOOK_OF_SAHAMUTT, 15, 0, 0, 12).requireStrength == ComputeItem(ITEM_BOOK_OF_SAHAMUTT, 15, 0, 0, 12).RequireStrength); - // Scrolls: energy from the level requirement, the level requirement + 4 per level. + // Scrolls: the energy as stored, the level requirement + 4 per level. const int wizardryEnhance = MakeItemType(15, 28); CHECK(stats(wizardryEnhance, 5).requireEnergy == 1058); CHECK(stats(wizardryEnhance, 5).requireLevel == 240); - // A level requirement given by level is the one the energy comes from. + // The "levelRequirement" basis, which no shipped profile uses any more: the energy comes + // from the level requirement, also one given by level. + const LevelBasisScrollProfile levelBasis(ClientDirectory); + constexpr int energyWeight = 118; ItemDefinition givenByLevel = *g_ItemDatabase.Find(wizardryEnhance); RequirementByLevel levels{}; levels.fill(100); givenByLevel.requirements.level = 0; + givenByLevel.requirements.energy = energyWeight; givenByLevel.requirementsByLevel.Set(ItemRequirement::Level, levels); g_ItemDatabase.Set(givenByLevel); items[wizardryEnhance].RequireLevel = 0; + items[wizardryEnhance].RequireEnergy = energyWeight; CHECK(stats(wizardryEnhance, 5).requireLevel == 100); - CHECK(stats(wizardryEnhance, 5).requireEnergy == 20 + items[wizardryEnhance].RequireEnergy * 100 * 4 / 100); + CHECK(stats(wizardryEnhance, 5).requireEnergy == 20 + energyWeight * 100 * 4 / 100); } // The item editor computes an item it has not stored from its definition: for every @@ -941,3 +947,57 @@ TEST_CASE("The item help window builds its content again only when what it shows CHECK(GetItemHelpContentKey(ITEM_BONE_BLADE) != namesInEnglish); g_ItemDatabase.SetDisplayLocale("en"); } + +// Scrolls and orbs keep their stat requirements as the values the game asks for (their profiles +// keep the base value), as OpenMU stores them. These are the values the drop level formula gave +// them before; +0 is the only level they have. +TEST_CASE("Scrolls and orbs ask for their requirements as stored [data][items]") +{ + struct StoredRequirements + { + int group; + int number; + int strength; + int dexterity; + int energy; + int vitality; + int leadership; + }; + constexpr StoredRequirements expected[] = { + {12, 8, 0, 0, 52, 0, 0}, {12, 9, 0, 0, 72, 0, 0}, {12, 10, 0, 0, 92, 0, 0}, + {12, 16, 596, 0, 0, 0, 0}, {12, 18, 0, 646, 0, 0, 0}, {12, 21, 0, 0, 79, 0, 0}, + {12, 22, 0, 0, 153, 0, 400}, {12, 23, 0, 0, 102, 0, 300}, {12, 24, 0, 0, 126, 0, 340}, + {12, 35, 0, 0, 150, 0, 70}, {12, 46, 0, 0, 168, 0, 0}, {12, 48, 0, 0, 84, 0, 0}, + {15, 0, 0, 0, 140, 0, 0}, {15, 1, 0, 0, 104, 0, 0}, {15, 2, 0, 0, 72, 0, 0}, + {15, 3, 0, 0, 40, 0, 0}, {15, 4, 0, 0, 160, 0, 0}, {15, 5, 0, 0, 88, 0, 0}, + {15, 6, 0, 0, 120, 0, 0}, {15, 7, 0, 0, 180, 0, 0}, {15, 8, 0, 0, 220, 0, 0}, + {15, 9, 0, 0, 260, 0, 0}, {15, 10, 0, 0, 56, 0, 0}, {15, 11, 0, 0, 345, 0, 0}, + {15, 12, 0, 0, 500, 0, 0}, {15, 13, 0, 0, 724, 0, 0}, {15, 14, 0, 0, 644, 0, 0}, + {15, 15, 0, 0, 408, 0, 0}, {15, 16, 0, 0, 953, 0, 0}, {15, 17, 0, 0, 849, 0, 0}, + {15, 18, 0, 0, 1052, 0, 0}, {15, 19, 0, 0, 245, 0, 0}, {15, 20, 0, 0, 150, 0, 0}, + {15, 21, 0, 0, 823, 0, 0}, {15, 22, 0, 0, 375, 0, 0}, {15, 23, 0, 0, 620, 0, 0}, + {15, 24, 0, 0, 180, 0, 0}, {15, 26, 0, 0, 663, 0, 0}, {15, 27, 0, 0, 912, 0, 0}, + {15, 28, 0, 0, 1058, 0, 0}, {15, 29, 0, 0, 1058, 0, 0}, {15, 34, 0, 0, 404, 0, 0}, + {15, 35, 0, 0, 132, 0, 0}, {15, 36, 0, 0, 80, 0, 0}, + }; + ShippedItemAttributes items; + for (const StoredRequirements& row : expected) + { + CAPTURE(row.group); + CAPTURE(row.number); + const int itemType = MakeItemType(row.group, row.number); + const ItemDefinition* item = g_ItemDatabase.Find(itemType); + REQUIRE(item != nullptr); + CHECK(item->requirements.strength == row.strength); + CHECK(item->requirements.dexterity == row.dexterity); + CHECK(item->requirements.energy == row.energy); + CHECK(item->requirements.vitality == row.vitality); + CHECK(item->requirements.leadership == row.leadership); + const auto values = GameLogic::Items::ComputeItemStats(items[itemType], {itemType, 0, 0, 0, 0}); + CHECK(values.requireStrength == row.strength); + CHECK(values.requireDexterity == row.dexterity); + CHECK(values.requireEnergy == row.energy); + CHECK(values.requireVitality == row.vitality); + CHECK(values.requireLeadership == row.leadership); + } +} diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp new file mode 100644 index 0000000000..2a1d859aec --- /dev/null +++ b/tests/data/test_item_sync_diff.cpp @@ -0,0 +1,823 @@ +#include "stdafx.h" + +#include "doctest.h" + +#ifdef _EDITOR + +#include "ClientDataScope.h" + +#include "UI/ItemTools/ItemEdits.h" +#include "UI/ItemTools/ItemSyncDiff.h" +#include "UI/ItemTools/MuItemSyncUI.h" + +#include "Data/DataHandler/ItemData/ItemDataHandler.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemJsonCommon.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace MuEditor::Items; +using namespace Data::Items; + +// The Item Sync window's diff: the client's data against an exchange file, and the side that +// changes with the checked rows. +namespace +{ +const std::filesystem::path ClientDirectory = std::filesystem::path(MU_TEST_DATA_DIR).parent_path(); + +constexpr int KrisType = MakeItemType(0, 0); +constexpr int ShortSwordType = MakeItemType(0, 1); +const std::vector UiLocales = {"en", "de", "es", "pt"}; + +// The shipped data as the client's side, and a copy of it as the file. +class Sides +{ +public: + Sides() : m_client(ClientDirectory) + { + I18N::SetLocale("en"); + std::string errorMessage; + REQUIRE(g_ItemDataHandler.Load(errorMessage)); + client = BuildItemExchange(g_ItemDatabase.GetAllSlots(), g_ItemDataHandler.GetStatFile()); + file = client; + file.source = ItemExchangeSourceOpenMU; + } + + ItemDefinition& FileItem(int itemType) + { + return Find(file, itemType); + } + + static ItemDefinition& Find(ItemExchangeFile& side, int itemType) + { + const auto item = std::ranges::find_if(side.items, [&](const ItemDefinition& entry) + { return MakeItemType(entry.group, entry.number) == itemType; }); + REQUIRE(item != side.items.end()); + return *item; + } + + ItemSyncDiff Diff(const KnownDifferences* known = nullptr, std::function isHeld = {}) const + { + return BuildItemSyncDiff(client, file, {known, UiLocales, std::move(isHeld)}); + } + + ItemExchangeFile client; + ItemExchangeFile file; + +private: + ClientDataScope m_client; +}; + +const SyncItem* FindRow(const ItemSyncDiff& diff, int itemType) +{ + const auto row = std::ranges::find_if(diff.items, [&](const SyncItem& entry) + { return MakeItemType(entry.group, entry.number) == itemType; }); + return row != diff.items.end() ? &*row : nullptr; +} + +const SyncField* FindField(const SyncItem& row, const std::string& field) +{ + const auto found = std::ranges::find(row.fields, field, &SyncField::field); + return found != row.fields.end() ? &*found : nullptr; +} + +const SyncFormula* FindFormula(const ItemSyncDiff& diff, const std::string& entry) +{ + const auto found = std::ranges::find(diff.formulas, entry, &SyncFormula::entry); + return found != diff.formulas.end() ? &*found : nullptr; +} + +// A folder of its own for a test's files. +class TemporaryFolder +{ +public: + TemporaryFolder() + : m_directory(std::filesystem::temp_directory_path() / + ("mu_test_item_sync_" + std::to_string(std::random_device{}()))) + { + std::filesystem::create_directories(m_directory); + } + + ~TemporaryFolder() + { + std::error_code ignored; + std::filesystem::remove_all(m_directory, ignored); + } + + TemporaryFolder(const TemporaryFolder&) = delete; + TemporaryFolder& operator=(const TemporaryFolder&) = delete; + + const std::filesystem::path& Path() const + { + return m_directory; + } + +private: + std::filesystem::path m_directory; +}; + +void WriteFile(const std::filesystem::path& file, const std::string& text) +{ + std::ofstream(file, std::ios::binary) << text; +} +} // namespace + +TEST_CASE("The same data on both sides gives no differences [data][items][editor]") +{ + Sides sides; + CHECK(sides.Diff().IsEmpty()); +} + +TEST_CASE( + "An import lists the file's new, changed and removed items and applies the checked ones [data][items][editor]") +{ + Sides sides; + ItemDefinition& kris = sides.FileItem(KrisType); + const BYTE width = kris.width; + ++kris.width; + kris.names.Set("de", "Dolch"); + ItemDefinition added = kris; + added.number = 100; + added.names = Data::LocalizedString::Parse("New Sword"); + sides.file.items.push_back(added); + std::erase_if(sides.file.items, + [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == ShortSwordType; }); + + ItemSyncDiff diff = sides.Diff(); + REQUIRE(diff.items.size() == 3); + const SyncItem* changed = FindRow(diff, KrisType); + REQUIRE(changed != nullptr); + CHECK(changed->change == SyncChange::Changed); + REQUIRE(changed->fields.size() == 2); + const SyncField* widthField = FindField(*changed, "width"); + REQUIRE(widthField != nullptr); + CHECK(widthField->current == std::to_string(width)); + CHECK(widthField->incoming == std::to_string(width + 1)); + CHECK(widthField->checked); + REQUIRE(FindField(*changed, "name.de") != nullptr); + const SyncItem* newRow = FindRow(diff, MakeItemType(0, 100)); + REQUIRE(newRow != nullptr); + CHECK(newRow->change == SyncChange::New); + CHECK(newRow->checked); + const SyncItem* removed = FindRow(diff, ShortSwordType); + REQUIRE(removed != nullptr); + CHECK(removed->change == SyncChange::Removed); + CHECK_FALSE(removed->checked); + + // The name is unchecked: only the width comes in. + for (SyncItem& row : diff.items) + { + for (SyncField& field : row.fields) + { + field.checked = field.checked && field.field != "name.de"; + } + } + std::vector issues; + ItemExchangeFile applied = ApplyItemSyncDiff(sides.client, sides.file, diff, issues); + CHECK(issues.empty()); + CHECK(Sides::Find(applied, KrisType).width == width + 1); + CHECK(Sides::Find(applied, KrisType).names.Get("de") == Sides::Find(sides.client, KrisType).names.Get("de")); + CHECK(Sides::Find(applied, MakeItemType(0, 100)).names.GetNeutral() == "New Sword"); + CHECK(Sides::Find(applied, ShortSwordType).Exists()); +} + +TEST_CASE("A name in a language the client does not show, or one the file lacks, is not taken [data][items][editor]") +{ + Sides sides; + ItemDefinition& kris = sides.FileItem(KrisType); + Data::LocalizedString withoutSpanish; + for (const std::string& locale : {"en", "de", "pt"}) + { + withoutSpanish.Set(locale, kris.names.Get(locale)); + } + withoutSpanish.Set("zh-CN", "Kris (zh)"); + kris.names = withoutSpanish; + CHECK(sides.Diff().IsEmpty()); +} + +TEST_CASE("An item the game holds keeps its size and slot or is not added, and a known difference stays unchecked " + "[data][items][editor]") +{ + Sides sides; + ItemDefinition& kris = sides.FileItem(KrisType); + ++kris.width; + kris.repairable = !kris.repairable; + std::erase_if(sides.file.items, + [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == ShortSwordType; }); + // The character holds an item of a type the client has no data for yet. + ItemDefinition heldNew = kris; + heldNew.number = 100; + sides.file.items.push_back(heldNew); + KnownDifferences known; + known.fields.insert({0, 0, "repairable"}); + + const ItemSyncDiff diff = + sides.Diff(&known, [](int itemType) + { return itemType == KrisType || itemType == ShortSwordType || itemType == MakeItemType(0, 100); }); + const SyncItem* row = FindRow(diff, KrisType); + REQUIRE(row != nullptr); + const SyncField* width = FindField(*row, "width"); + REQUIRE(width != nullptr); + CHECK_FALSE(width->locked.empty()); + CHECK_FALSE(width->checked); + const SyncField* repairable = FindField(*row, "repairable"); + REQUIRE(repairable != nullptr); + CHECK(repairable->known); + CHECK_FALSE(repairable->checked); + const SyncItem* removed = FindRow(diff, ShortSwordType); + REQUIRE(removed != nullptr); + CHECK_FALSE(removed->locked.empty()); + const SyncItem* added = FindRow(diff, MakeItemType(0, 100)); + REQUIRE(added != nullptr); + CHECK(added->change == SyncChange::New); + CHECK_FALSE(added->locked.empty()); + CHECK_FALSE(added->checked); + + // Even checked, a locked field and a locked removal stay as they are. + ItemSyncDiff checkedDiff = diff; + for (SyncItem& entry : checkedDiff.items) + { + entry.checked = true; + for (SyncField& field : entry.fields) + { + field.checked = true; + } + } + std::vector issues; + ItemExchangeFile applied = ApplyItemSyncDiff(sides.client, sides.file, checkedDiff, issues); + CHECK(Sides::Find(applied, KrisType).width == Sides::Find(sides.client, KrisType).width); + CHECK(Sides::Find(applied, KrisType).repairable == kris.repairable); + CHECK(Sides::Find(applied, ShortSwordType).Exists()); + CHECK(std::ranges::none_of(applied.items, [](const ItemDefinition& item) + { return MakeItemType(item.group, item.number) == MakeItemType(0, 100); })); +} + +TEST_CASE("A drop level OpenMU cannot keep is not taken by default [data][items][editor]") +{ + Sides sides; + REQUIRE(Sides::Find(sides.client, ITEM_HORN_OF_FENRIR).level == 300); + sides.FileItem(ITEM_HORN_OF_FENRIR).level = 255; + const ItemSyncDiff diff = sides.Diff(); + const SyncItem* row = FindRow(diff, ITEM_HORN_OF_FENRIR); + REQUIRE(row != nullptr); + const SyncField* level = FindField(*row, "level"); + REQUIRE(level != nullptr); + CHECK_FALSE(level->inexact.empty()); + CHECK_FALSE(level->checked); +} + +TEST_CASE("The formulas differ by drop level bonus, table, term and whole profile [data][items][editor]") +{ + Sides sides; + ItemStatFile& formulas = *sides.file.formulas; + formulas.excellentDropLevelBonus = 30; + formulas.tables.front().values[1] = 4; + const std::string tableEntry = "tables." + formulas.tables.front().name; + WrittenStatProfile* weapon = nullptr; + for (WrittenStatProfile& profile : formulas.profiles) + { + weapon = profile.name == "weapon" ? &profile : weapon; + } + REQUIRE(weapon != nullptr); + weapon->requirements.strengthPerOptionLevel = 4; + WrittenStatProfile added; + added.name = "rune"; + added.requirements.levelPerItemLevel = 2; + formulas.profiles.push_back(added); + + ItemSyncDiff diff = sides.Diff(); + CHECK(diff.items.empty()); + REQUIRE(diff.formulas.size() == 4); + REQUIRE(FindFormula(diff, "dropLevelBonus.excellent") != nullptr); + CHECK(FindFormula(diff, "dropLevelBonus.excellent")->incoming == "30"); + REQUIRE(FindFormula(diff, tableEntry) != nullptr); + const SyncFormula* term = FindFormula(diff, "profiles.weapon.requirements.strengthPerOptionLevel"); + REQUIRE(term != nullptr); + CHECK(term->current == "5"); + CHECK(term->incoming == "4"); + const SyncFormula* profile = FindFormula(diff, "profiles.rune"); + REQUIRE(profile != nullptr); + CHECK_FALSE(profile->current); + + // All but the table. + for (SyncFormula& row : diff.formulas) + { + row.checked = row.entry != tableEntry; + } + std::vector issues; + const ItemExchangeFile applied = ApplyItemSyncDiff(sides.client, sides.file, diff, issues); + CHECK(issues.empty()); + REQUIRE(applied.formulas); + CHECK(applied.formulas->excellentDropLevelBonus == 30); + CHECK(applied.formulas->tables.front().values == sides.client.formulas->tables.front().values); + const auto rune = std::ranges::find(applied.formulas->profiles, std::string("rune"), &WrittenStatProfile::name); + REQUIRE(rune != applied.formulas->profiles.end()); + CHECK(rune->requirements.levelPerItemLevel == 2); + const auto appliedWeapon = + std::ranges::find(applied.formulas->profiles, std::string("weapon"), &WrittenStatProfile::name); + REQUIRE(appliedWeapon != applied.formulas->profiles.end()); + CHECK(appliedWeapon->requirements.strengthPerOptionLevel == 4); +} + +// A formula entry only the client has starts unchecked, as a removed item does: taking it away +// would leave the orbs naming a profile that is not there. +TEST_CASE("A formula entry only the client has starts unchecked [data][items][editor]") +{ + Sides sides; + std::erase_if(sides.file.formulas->profiles, + [](const WrittenStatProfile& profile) { return profile.name == "orb"; }); + const ItemSyncDiff diff = sides.Diff(); + const SyncFormula* orb = FindFormula(diff, "profiles.orb"); + REQUIRE(orb != nullptr); + CHECK(orb->current.has_value()); + CHECK_FALSE(orb->incoming.has_value()); + CHECK_FALSE(orb->checked); +} + +TEST_CASE("Formulas the item stat file's checks refuse are not taken [data][items][editor]") +{ + Sides sides; + // The file's armour profile names a table only the file has. + ItemStatFile& formulas = *sides.file.formulas; + LevelBonusTable table = formulas.tables.front(); + table.name = "runeTable"; + formulas.tables.push_back(table); + for (WrittenStatProfile& profile : formulas.profiles) + { + if (profile.name == "armor") + { + profile.stats[static_cast(ItemStat::Defense)].table = "runeTable"; + } + } + ItemSyncDiff diff = sides.Diff(); + // Only the term, not the table it names. + for (SyncFormula& row : diff.formulas) + { + row.checked = row.entry != "tables.runeTable"; + } + std::vector issues; + const ItemExchangeFile applied = ApplyItemSyncDiff(sides.client, sides.file, diff, issues); + CHECK(HasErrors(issues)); + CHECK(*applied.formulas == *sides.client.formulas); +} + +// A refusal by the formulas cannot be traced back to a formula row: the window unchecks them all, +// so the next Apply does not fail the same way. +TEST_CASE("Item Sync unchecks the formula rows when the formulas refuse an import [data][items][editor]") +{ + Sides sides; + g_ItemEdits.GetHistory().Clear(); + ItemStatFile& formulas = *sides.file.formulas; + LevelBonusTable table = formulas.tables.front(); + table.name = "runeTable"; + formulas.tables.push_back(table); + for (WrittenStatProfile& profile : formulas.profiles) + { + if (profile.name == "armor") + { + profile.stats[static_cast(ItemStat::Defense)].table = "runeTable"; + } + } + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + + g_MuItemSyncUI.Compare(file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + // The term, not the table it names. + for (SyncFormula& row : g_MuItemSyncUI.GetDiff()->formulas) + { + row.checked = row.entry != "tables.runeTable"; + } + CHECK_FALSE(g_MuItemSyncUI.Apply()); + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK(std::ranges::none_of(g_MuItemSyncUI.GetDiff()->formulas, &SyncFormula::checked)); + CHECK_FALSE(g_ItemEdits.GetHistory().CanUndo()); +} + +// The window's Import: the checked rows change the client's data as one step of the undo +// history, which undoes them all. +TEST_CASE("Item Sync imports the checked rows of a file as one undo step [data][items][editor]") +{ + Sides sides; + g_ItemEdits.GetHistory().Clear(); + const ItemDefinition kris = *g_ItemDatabase.Find(KrisType); + const ItemStatFile statFile = g_ItemDataHandler.GetStatFile(); + ++sides.FileItem(KrisType).width; + ItemDefinition added = sides.FileItem(KrisType); + added.number = 100; + added.names = Data::LocalizedString::Parse("New Sword"); + added.width = 2; + sides.file.items.push_back(added); + // A new item whose file names a profile its rules do not give: the import keeps it as a pick. + ItemDefinition picked = added; + picked.number = 101; + picked.names = Data::LocalizedString::Parse("Picked Sword"); + picked.statProfile = "sceptre"; + sides.file.items.push_back(picked); + std::erase_if(sides.file.items, + [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == ShortSwordType; }); + sides.file.formulas->excellentDropLevelBonus = 30; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + + g_MuItemSyncUI.Compare(file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK(g_MuItemSyncUI.GetDiff()->items.size() == 4); + CHECK(g_MuItemSyncUI.GetDiff()->formulas.size() == 1); + REQUIRE(g_MuItemSyncUI.Apply()); + + CHECK(g_ItemDatabase.Find(KrisType)->width == kris.width + 1); + // Its client-only fields stay. + CHECK(g_ItemDatabase.Find(KrisType)->weaponKind == kris.weaponKind); + const ItemDefinition* newSword = g_ItemDatabase.Find(MakeItemType(0, 100)); + REQUIRE(newSword != nullptr); + CHECK(newSword->twoHanded); + CHECK(newSword->weaponKind == WeaponKind::Sword); + CHECK(newSword->statProfile == "weapon"); + const ItemDefinition* pickedSword = g_ItemDatabase.Find(MakeItemType(0, 101)); + REQUIRE(pickedSword != nullptr); + CHECK(pickedSword->statProfile == "sceptre"); + // The removal was not checked. + CHECK(g_ItemDatabase.Find(ShortSwordType) != nullptr); + CHECK(g_ItemDataHandler.GetStatFile().excellentDropLevelBonus == 30); + CHECK(g_ItemDataHandler.GetStatFile().profileRules == statFile.profileRules); + CHECK(g_ItemEdits.GetHistory().GetUndoLabel() == I18N::Editor::ImportItems); + // What is left: the removal. + REQUIRE(g_MuItemSyncUI.GetDiff()); + REQUIRE(g_MuItemSyncUI.GetDiff()->items.size() == 1); + CHECK(g_MuItemSyncUI.GetDiff()->items[0].change == SyncChange::Removed); + + REQUIRE(g_ItemEdits.Undo()); + CHECK(g_ItemDatabase.Find(KrisType)->width == kris.width); + CHECK(g_ItemDatabase.Find(MakeItemType(0, 100)) == nullptr); + CHECK(g_ItemDatabase.Find(MakeItemType(0, 101)) == nullptr); + CHECK(g_ItemDataHandler.GetStatFile() == statFile); + g_ItemEdits.GetHistory().Clear(); +} + +// The checks of a save run on the data as it would be: an item that names a profile the +// formulas have not got refuses the import, and nothing changes. +TEST_CASE("Item Sync refuses an import the checks of a save refuse [data][items][editor]") +{ + Sides sides; + g_ItemEdits.GetHistory().Clear(); + const ItemDefinition kris = *g_ItemDatabase.Find(KrisType); + ItemDefinition& fileKris = sides.FileItem(KrisType); + fileKris.statProfile = "rune"; + ++fileKris.width; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + + g_MuItemSyncUI.Compare(file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK_FALSE(g_MuItemSyncUI.Apply()); + CHECK(*g_ItemDatabase.Find(KrisType) == kris); + CHECK_FALSE(g_ItemEdits.GetHistory().CanUndo()); + // The item's row is unchecked. + const SyncItem* row = FindRow(*g_MuItemSyncUI.GetDiff(), KrisType); + REQUIRE(row != nullptr); + CHECK(std::ranges::none_of(row->fields, [](const SyncField& field) { return field.checked; })); + CHECK(g_MuItemSyncUI.GetMessages().size() > 1); +} + +// The window's Export writes the client's items and formulas without a diff: into a new file right +// away, into a file that is there after the confirmation that says what changes in it, and not at +// all into a file that has the client's data already. +TEST_CASE("Item Sync exports the client's items and formulas [data][items][editor]") +{ + Sides sides; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + g_MuItemSyncUI.Export(file); + CHECK_FALSE(g_MuItemSyncUI.GetPendingExport()); + CHECK_FALSE(g_MuItemSyncUI.GetExportResult().empty()); + const std::optional exported = Data::Items::Json::ReadTextFile(file); + REQUIRE(exported); + std::vector issues; + ItemExchangeFile written; + ReadItemExchangeJson(*exported, file.generic_string(), written, issues); + CHECK(issues.empty()); + CHECK(written.source == ItemExchangeSourceMuMain); + CHECK(written.items == sides.client.items); + CHECK(written.formulas == sides.client.formulas); + + SUBCASE("a file that has the client's data already") + { + g_MuItemSyncUI.Export(file); + CHECK_FALSE(g_MuItemSyncUI.GetPendingExport()); + CHECK_FALSE(g_MuItemSyncUI.GetExportResult().empty()); + CHECK(Data::Items::Json::ReadTextFile(file) == exported); + } + SUBCASE("a file that is there asks first, with what changes in it") + { + // One item changed, one only in the file, one only in the client, one formula entry. + ItemExchangeFile other = sides.client; + ++Sides::Find(other, KrisType).width; + ItemDefinition extra = Sides::Find(other, KrisType); + extra.number = 100; + other.items.push_back(extra); + std::erase_if(other.items, [](const ItemDefinition& item) + { return MakeItemType(item.group, item.number) == ShortSwordType; }); + other.formulas->excellentDropLevelBonus = 30; + WriteFile(file, WriteItemExchangeJson(other)); + const std::optional before = Data::Items::Json::ReadTextFile(file); + + g_MuItemSyncUI.Export(file); + REQUIRE(g_MuItemSyncUI.GetPendingExport()); + const ItemSyncExportPreview preview = *g_MuItemSyncUI.GetPendingExport(); + CHECK(preview.readable); + CHECK(preview.added == 1); + CHECK(preview.changed == 1); + CHECK(preview.removed == 1); + CHECK(preview.formulaEntries == 1); + CHECK(Data::Items::Json::ReadTextFile(file) == before); + SUBCASE("cancelled") + { + g_MuItemSyncUI.CancelExport(); + CHECK_FALSE(g_MuItemSyncUI.ConfirmExport()); + CHECK(Data::Items::Json::ReadTextFile(file) == before); + } + SUBCASE("confirmed") + { + REQUIRE(g_MuItemSyncUI.ConfirmExport()); + CHECK_FALSE(g_MuItemSyncUI.GetPendingExport()); + CHECK(Data::Items::Json::ReadTextFile(file) == exported); + } + } + SUBCASE("a file that is not an exchange file asks first too") + { + WriteFile(file, "not an exchange file"); + g_MuItemSyncUI.Export(file); + REQUIRE(g_MuItemSyncUI.GetPendingExport()); + CHECK_FALSE(g_MuItemSyncUI.GetPendingExport()->readable); + REQUIRE(g_MuItemSyncUI.ConfirmExport()); + CHECK(Data::Items::Json::ReadTextFile(file) == exported); + } +} + +// Compare two files lists, read-only, what changes from file A to file B, in every language (also +// one only file A has); nothing can be applied, and an import shows the import again. +TEST_CASE("Item Sync compares two files read-only [data][items][editor]") +{ + Sides sides; + const ItemDefinition clientKris = *g_ItemDatabase.Find(KrisType); + TemporaryFolder folder; + const std::filesystem::path a = folder.Path() / "Client.json"; + const std::filesystem::path b = folder.Path() / "Server.json"; + ItemExchangeFile fileA = sides.client; + ItemExchangeFile fileB = sides.client; + fileB.source = ItemExchangeSourceOpenMU; + ++Sides::Find(fileB, KrisType).width; + Sides::Find(fileA, ShortSwordType).names.Set("zh-CN", "Short Sword (zh)"); + ItemDefinition added = Sides::Find(fileB, KrisType); + added.number = 100; + added.names = Data::LocalizedString::Parse("New Sword"); + fileB.items.push_back(added); + fileB.formulas->excellentDropLevelBonus = 30; + // Only file A has (0,101), and file A has (0,3) twice, as an OpenMU export can. + ItemDefinition onlyInA = Sides::Find(fileA, KrisType); + onlyInA.number = 101; + onlyInA.names = Data::LocalizedString::Parse("Old Sword"); + fileA.items.push_back(onlyInA); + const int twiceType = MakeItemType(0, 3); + fileA.items.push_back(Sides::Find(fileA, twiceType)); + WriteFile(a, WriteItemExchangeJson(fileA)); + WriteFile(b, WriteItemExchangeJson(fileB)); + + g_MuItemSyncUI.CompareFiles(a, b); + CHECK(g_MuItemSyncUI.IsComparingFiles()); + REQUIRE(g_MuItemSyncUI.GetDiff()); + const ItemSyncDiff& diff = *g_MuItemSyncUI.GetDiff(); + CHECK(diff.items.size() == 5); + const SyncItem* removed = FindRow(diff, MakeItemType(0, 101)); + REQUIRE(removed != nullptr); + CHECK(removed->change == SyncChange::Removed); + const SyncField* removedName = FindField(*removed, "name.en"); + REQUIRE(removedName != nullptr); + CHECK(removedName->current.has_value()); + CHECK_FALSE(removedName->incoming.has_value()); + // File B has it once: changed, with the name file B has, locked. + const SyncItem* twice = FindRow(diff, twiceType); + REQUIRE(twice != nullptr); + CHECK(twice->change == SyncChange::Changed); + CHECK_FALSE(twice->name.empty()); + CHECK(twice->locked == std::string(I18N::Editor::ItIsInFileAMoreThanOnce)); + const SyncItem* kris = FindRow(diff, KrisType); + REQUIRE(kris != nullptr); + CHECK(kris->change == SyncChange::Changed); + CHECK(FindField(*kris, "width") != nullptr); + const SyncItem* shortSword = FindRow(diff, ShortSwordType); + REQUIRE(shortSword != nullptr); + const SyncField* name = FindField(*shortSword, "name.zh-CN"); + REQUIRE(name != nullptr); + CHECK(name->current.has_value()); + CHECK_FALSE(name->incoming.has_value()); + const SyncItem* newSword = FindRow(diff, MakeItemType(0, 100)); + REQUIRE(newSword != nullptr); + CHECK(newSword->change == SyncChange::New); + CHECK(FindFormula(diff, "dropLevelBonus.excellent") != nullptr); + + CHECK_FALSE(g_MuItemSyncUI.Apply()); + CHECK(*g_ItemDatabase.Find(KrisType) == clientKris); + g_MuItemSyncUI.Compare(b); + CHECK_FALSE(g_MuItemSyncUI.IsComparingFiles()); +} + +// The import is refused while nothing is checked, and while the client's data changed since the +// comparison: the diff shows the data as it was. +TEST_CASE("Item Sync applies nothing while nothing is checked or the client's data changed [data][items][editor]") +{ + Sides sides; + g_ItemEdits.GetHistory().Clear(); + const ItemDefinition kris = *g_ItemDatabase.Find(KrisType); + ++sides.FileItem(KrisType).width; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + + g_MuItemSyncUI.Compare(file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::None); + SUBCASE("nothing checked") + { + for (SyncItem& row : g_MuItemSyncUI.GetDiff()->items) + { + row.checked = false; + for (SyncField& field : row.fields) + { + field.checked = false; + } + } + for (SyncFormula& row : g_MuItemSyncUI.GetDiff()->formulas) + { + row.checked = false; + } + CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::NothingChecked); + } + SUBCASE("the client's data changed") + { + ItemDefinition shortSword = *g_ItemDatabase.Find(ShortSwordType); + shortSword.damageMax = static_cast(shortSword.damageMax + 1); + REQUIRE(g_ItemEdits.SetItem("test", shortSword)); + CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::DataChanged); + // Undone, the data is as compared, but it changed twice since: compare again. + REQUIRE(g_ItemEdits.Undo()); + CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::DataChanged); + } + CHECK_FALSE(g_MuItemSyncUI.Apply()); + CHECK(g_MuItemSyncUI.GetResult().empty()); + CHECK(g_ItemDatabase.Find(KrisType)->width == kris.width); + g_ItemEdits.GetHistory().Clear(); +} + +// After a partial Apply the window compares again: the rows that were not checked stay listed, +// and unchecked, so a second click does not take them after all. The confirmation counts what +// is checked. +TEST_CASE("Item Sync leaves what was not checked unchecked after an Apply [data][items][editor]") +{ + Sides sides; + g_ItemEdits.GetHistory().Clear(); + const BYTE krisWidth = g_ItemDatabase.Find(KrisType)->width; + ++sides.FileItem(KrisType).width; + ++sides.FileItem(ShortSwordType).width; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + + g_MuItemSyncUI.Compare(file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + std::vector& rows = g_MuItemSyncUI.GetDiff()->items; + const auto shortSword = std::ranges::find_if(rows, [](const SyncItem& row) + { return MakeItemType(row.group, row.number) == ShortSwordType; }); + REQUIRE(shortSword != rows.end()); + for (SyncField& field : shortSword->fields) + { + field.checked = false; + } + const CMuItemSyncUI::CheckedChanges changes = g_MuItemSyncUI.CountCheckedChanges(); + CHECK(changes.added == 0); + CHECK(changes.changed == 1); + CHECK(changes.removed == 0); + CHECK(changes.formulaEntries == 0); + REQUIRE(g_MuItemSyncUI.Apply()); + CHECK(g_ItemDatabase.Find(KrisType)->width == krisWidth + 1); + + REQUIRE(g_MuItemSyncUI.GetDiff()); + REQUIRE(g_MuItemSyncUI.GetDiff()->items.size() == 1); + const SyncItem& left = g_MuItemSyncUI.GetDiff()->items[0]; + CHECK(MakeItemType(left.group, left.number) == ShortSwordType); + CHECK_FALSE(left.fields.empty()); + CHECK(std::ranges::none_of(left.fields, &SyncField::checked)); + CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::NothingChecked); + CHECK_FALSE(g_MuItemSyncUI.GetResult().empty()); + REQUIRE(g_ItemEdits.Undo()); + g_ItemEdits.GetHistory().Clear(); +} + +// A new item whose fields changed after the comparison (e.g. a value set back to its default) is +// taken without that field instead of failing. +TEST_CASE("Applying a diff takes a new item that lost a field since the comparison [data][items][editor]") +{ + Sides sides; + ItemDefinition added = sides.FileItem(KrisType); + added.number = 100; + added.names = Data::LocalizedString::Parse("New Sword"); + sides.file.items.push_back(added); + const int addedType = MakeItemType(0, 100); + const ItemSyncDiff diff = sides.Diff(); + const SyncItem* row = FindRow(diff, addedType); + REQUIRE(row != nullptr); + REQUIRE(row->change == SyncChange::New); + REQUIRE(FindField(*row, "durability") != nullptr); + Sides::Find(sides.file, addedType).durability = 0; + + std::vector issues; + ItemExchangeFile applied; + CHECK_NOTHROW(applied = ApplyItemSyncDiff(sides.client, sides.file, diff, issues)); + CHECK(Sides::Find(applied, addedType).durability == 0); +} + +// A changed row whose item the client no longer has (its data changed since the diff) is left out +// instead of failing. +TEST_CASE("Applying a diff leaves out a changed item the client no longer has [data][items][editor]") +{ + Sides sides; + ++sides.FileItem(KrisType).width; + const ItemSyncDiff diff = sides.Diff(); + REQUIRE(FindRow(diff, KrisType) != nullptr); + std::erase_if(sides.client.items, + [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == KrisType; }); + + std::vector issues; + ItemExchangeFile applied; + CHECK_NOTHROW(applied = ApplyItemSyncDiff(sides.client, sides.file, diff, issues)); + CHECK(std::ranges::none_of(applied.items, [](const ItemDefinition& item) + { return MakeItemType(item.group, item.number) == KrisType; })); +} + +// OpenMU does not keep (group, number) unique: an item a file has twice is a locked row that +// the import leaves as it is, while the rest of the file is imported. +TEST_CASE("An item the file has twice is a locked row of the import [data][items][editor]") +{ + Sides sides; + g_ItemEdits.GetHistory().Clear(); + const ItemDefinition kris = *g_ItemDatabase.Find(KrisType); + const BYTE shortSwordWidth = g_ItemDatabase.Find(ShortSwordType)->width; + ItemDefinition copy = sides.FileItem(KrisType); + ++copy.width; + sides.file.items.push_back(copy); + ++sides.FileItem(ShortSwordType).width; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + + g_MuItemSyncUI.Compare(file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + const SyncItem* row = FindRow(*g_MuItemSyncUI.GetDiff(), KrisType); + REQUIRE(row != nullptr); + CHECK(row->change == SyncChange::Changed); + CHECK(row->fields.empty()); + CHECK_FALSE(row->locked.empty()); + CHECK(g_MuItemSyncUI.GetMessages().size() == 1); + REQUIRE(g_MuItemSyncUI.Apply()); + CHECK(*g_ItemDatabase.Find(KrisType) == kris); + CHECK(g_ItemDatabase.Find(ShortSwordType)->width == shortSwordWidth + 1); + + REQUIRE(g_ItemEdits.Undo()); + g_ItemEdits.GetHistory().Clear(); +} + +TEST_CASE("The known differences are written and read back [data][items][editor]") +{ + KnownDifferences known; + known.fields.insert({13, 4, "repairable"}); + known.fields.insert({13, 4, ""}); + known.fields.insert({7, 62, ""}); + known.formulas.insert("default.requirements.leadership"); + const std::string text = WriteKnownDifferencesJson(known); + const std::filesystem::path directory = std::filesystem::temp_directory_path() / + ("mu_test_known_differences_" + std::to_string(std::random_device{}())); + std::filesystem::create_directories(directory); + const std::filesystem::path file = directory / "KnownDifferences.json"; + std::ofstream(file, std::ios::binary) << text; + std::vector issues; + CHECK(LoadKnownDifferences(file, issues) == known); + CHECK(issues.empty()); + CHECK(LoadKnownDifferences(directory / "missing.json", issues) == KnownDifferences{}); + CHECK(issues.empty()); + std::error_code ignored; + std::filesystem::remove_all(directory, ignored); +} + +#endif // _EDITOR diff --git a/tests/data/test_item_value_formulas.cpp b/tests/data/test_item_value_formulas.cpp index 5253237477..ae9ac9c4c9 100644 --- a/tests/data/test_item_value_formulas.cpp +++ b/tests/data/test_item_value_formulas.cpp @@ -8,6 +8,7 @@ #include "UI/ItemTools/ItemValueFormulaText.h" #include "UI/ItemTools/ItemStatProfileWidgets.h" #include "ClientDataScope.h" +#include "LevelBasisScrollProfile.h" #include "Data/DataHandler/ItemData/ItemDataHandler.h" #include "Data/GameData/ItemData/ItemDatabase.h" #include "Data/GameData/ItemData/ItemType.h" @@ -154,33 +155,44 @@ TEST_CASE("The item tools show the formula of each value of an item [data][items "to +15) [item: requirementsByLevel]"); // Book of Samut CHECK(requirement(5, 21, ItemRequirement::Energy) == "20 + 135 × (52 + 1 × item level) × 3 / 100 [book]"); - // Scroll of Wizardry Enhance and Scroll of Fire Ball: energy from the level requirement. - CHECK(requirement(15, 28, ItemRequirement::Energy) == - "20 + 118 × 220 × 4 / 100 at every item level (220: the level requirement) [scroll]"); - CHECK(requirement(15, 3, ItemRequirement::Energy) == - "20 + 100 × (5 + 3 × item level) × 4 / 100 (no level requirement: from the drop level) [scroll]"); + // Scroll of Wizardry Enhance, Scroll of Fire Ball and Healing Orb: the energy as stored. + CHECK(requirement(15, 28, ItemRequirement::Energy) == "1058, kept as is [scroll]"); + CHECK(requirement(15, 3, ItemRequirement::Energy) == "40, kept as is [scroll]"); + CHECK(requirement(12, 8, ItemRequirement::Energy) == "52, kept as is [orb]"); // Scroll of Critical Damage: leadership kept as it is. CHECK(requirement(12, 23, ItemRequirement::Leadership) == "300, kept as is [default]"); // The Horn of Fenrir, whose profile is picked. CHECK(requirement(13, 37, ItemRequirement::Level) == "300 at every item level [default]; excellent options do not raise it [item: excellentRaisesLevel]"); - // A scroll's energy takes the level requirement given by level where it is above 0. - ItemDefinition poison = *g_ItemDatabase.Find(MakeItemType(15, 0)); - RequirementByLevel levels{}; - poison.requirementsByLevel.Set(ItemRequirement::Level, levels); - CHECK(DescribeRequirementFormula(poison, ItemRequirement::Energy, ExpandItemStatProfile(poison)).text == - "20 + 100 × (30 + 3 × item level) × 4 / 100 (no level requirement: from the drop level) [scroll]"); - levels.fill(50); - levels[0] = 0; - poison.requirementsByLevel.Set(ItemRequirement::Level, levels); - CHECK(DescribeRequirementFormula(poison, ItemRequirement::Energy, ExpandItemStatProfile(poison)).text == - "20 + 100 × level requirement × 4 / 100 (the level requirement given at each item level); where the level " - "requirement is 0: 20 + 100 × (30 + 3 × item level) × 4 / 100 [scroll]"); - levels[0] = 50; - poison.requirementsByLevel.Set(ItemRequirement::Level, levels); - CHECK(DescribeRequirementFormula(poison, ItemRequirement::Energy, ExpandItemStatProfile(poison)).text == - "20 + 100 × level requirement × 4 / 100 (the level requirement given at each item level) [scroll]"); + // The "levelRequirement" basis, which no shipped profile uses any more: the energy takes + // the level requirement given by level where it is above 0. + { + const LevelBasisScrollProfile levelBasis(ClientDirectory); + ItemDefinition poison = *g_ItemDatabase.Find(MakeItemType(15, 0)); + poison.requirements.energy = 100; + RequirementByLevel levels{}; + poison.requirementsByLevel.Set(ItemRequirement::Level, levels); + CHECK(DescribeRequirementFormula(poison, ItemRequirement::Energy, + ExpandStatProfile(levelBasis.GetFile(), "scroll")) + .text == + "20 + 100 × (30 + 3 × item level) × 4 / 100 (no level requirement: from the drop level) [scroll]"); + levels.fill(50); + levels[0] = 0; + poison.requirementsByLevel.Set(ItemRequirement::Level, levels); + CHECK( + DescribeRequirementFormula(poison, ItemRequirement::Energy, + ExpandStatProfile(levelBasis.GetFile(), "scroll")) + .text == + "20 + 100 × level requirement × 4 / 100 (the level requirement given at each item level); where the level " + "requirement is 0: 20 + 100 × (30 + 3 × item level) × 4 / 100 [scroll]"); + levels[0] = 50; + poison.requirementsByLevel.Set(ItemRequirement::Level, levels); + CHECK(DescribeRequirementFormula(poison, ItemRequirement::Energy, + ExpandStatProfile(levelBasis.GetFile(), "scroll")) + .text == + "20 + 100 × level requirement × 4 / 100 (the level requirement given at each item level) [scroll]"); + } // Excellent options raise only a level requirement above 0. ItemDefinition krisByLevel = *g_ItemDatabase.Find(MakeItemType(0, 0));