From 7141ded6e0be1b5e9d75f5d0f89054663c8d4fec Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 22:15:15 +0200 Subject: [PATCH 01/32] docs(items): the design of phase 7, the item sync with OpenMU Phase 7 moves the item data both sides have, and the item formulas, between the client and OpenMU: MuEditor exports and imports an exchange file and updates from the server, each with a diff the user checks by hand. It has five steps, one PR each: the exchange file with Export and Import (MuMain), the item formulas as data, a read-only export endpoint and an import page (OpenMU), and Update from server (MuMain). The design replaces section 6 and settles the decisions taken for it: the exchange file's fields in the item files' names and meanings, the formulas part, the orbs, crystals and scrolls keeping their requirements as values as OpenMU does, the known differences kept in the repo, the endpoint behind an API key with rate limits and a cached export, and the key from an environment variable. The OpenMU notes are brought up to date: the rule flags of PR A, the configuration download that exists, and the fields its item definitions have. --- .../2026-09-25-data-driven-items-design.md | 304 +++++++++++++++--- 1 file changed, 259 insertions(+), 45 deletions(-) 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..d3b702a073 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 with a diff the user checks by hand, 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,219 @@ 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** (new, changed and removed items and formula + entries, field by field) that the user checks by hand; only the checked + rows are applied. The same holds for an export into an existing file and + for OpenMU's import page. +- 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; + 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 (an error row; neither side applies it). +- 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). + +**The Item Sync window** (MuEditor, `_EDITOR` builds only; the Item Sync +tool of section 9): + +- **Export…**: a save dialog picks the file. The diff compares the client's + data with what the file holds (nothing for a new file); the checked rows + are written into it, the others keep the file's content. +- **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. +- **Update from server** (step 4) downloads the server's file and goes on as + Import. +- 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 new 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. +- **New 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. +- **Apply:** 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 +590,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, each 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 +614,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), each 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 +748,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 +1215,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()` From 32b0a5670228fbcda349a4b8fc49167281945048 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 22:28:33 +0200 Subject: [PATCH 02/32] feat(items): the orbs, crystals and scrolls keep their requirements as values The 44 items of groups 12 and 15 that have stat requirements and no slot stored a weight that the drop level formula grew into the requirement. OpenMU stores the value the game asks for instead, so they do too now, for the item exchange with OpenMU (phase 7): the scroll profile and a new orb profile, with a rule of its own for group 12, keep the stat requirements as stored. A test pins the 44 values at +0, where the game asks for exactly what it did before; it shows these items at no other level (above +0 the value now stays as it is, as on OpenMU). The Orb of Summoning keeps its energy by level. The bmd files of the original client hold weights for these items: Import from bmd turns them into the values with the original client's formula, and Export as bmd and Export as S6E3 write the smallest weight that gives each value, with a note where none gives it exactly. The tests of the "levelRequirement" basis, which no shipped profile uses any more, install the old scroll profile for their scope. --- docs/item-data.md | 24 +-- .../UI/ItemEditor/ItemEditorActions.cpp | 7 +- src/bin/Data/Items/Group12_Wing.json | 36 ++-- src/bin/Data/Items/Group15_Etc.json | 64 +++---- src/bin/Data/Items/Stats/ItemStats.json | 15 +- .../DataHandler/ItemData/ItemBmdImport.cpp | 90 ++++++++++ .../Data/DataHandler/ItemData/ItemBmdImport.h | 13 ++ .../DataHandler/ItemData/ItemDataHandler.cpp | 47 +++++- .../DataHandler/ItemData/ItemDataHandler.h | 5 +- tests/data/LevelBasisScrollProfile.h | 55 ++++++ tests/data/item_tooltip_snapshot.txt | 156 +++++++++--------- tests/data/test_item_database_files.cpp | 97 ++++++++++- tests/data/test_item_requirement_weights.cpp | 13 +- tests/data/test_item_stats.cpp | 72 +++++++- tests/data/test_item_value_formulas.cpp | 54 +++--- 15 files changed, 576 insertions(+), 172 deletions(-) create mode 100644 tests/data/LevelBasisScrollProfile.h diff --git a/docs/item-data.md b/docs/item-data.md index 9e39a6afe4..84cb7e9eb8 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -117,7 +117,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 +129,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) | | `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 +433,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 +444,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 +463,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 +887,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 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/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..6592adc8ac 100644 --- a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp @@ -7,6 +7,7 @@ #include "Data/GameData/ItemData/ItemType.h" #include "Core/Text/Utf8.h" +#include #include #include #include @@ -297,6 +298,10 @@ void ImportItem(const std::vector& files, int itemType, ItemBmdImp CopyItemAttributeStats(MergeStats(records, itemType, result.repairs), definition); definition.group = GetItemGroup(itemType); definition.number = GetItemNumber(itemType); + if (HasRequirementWeightsInBmd(definition)) + { + ToRequirementValues(definition); + } for (const LanguageRecord& record : records) { // Also a name equal to the English one: the item can have the same @@ -310,6 +315,91 @@ 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; + WORD ItemRequirements::* value; + WORD ITEM_ATTRIBUTE::* weight; +}; +constexpr GrownRequirement GrownRequirements[] = { + {ItemRequirement::Strength, &ItemRequirements::strength, &ITEM_ATTRIBUTE::RequireStrength}, + {ItemRequirement::Dexterity, &ItemRequirements::dexterity, &ITEM_ATTRIBUTE::RequireDexterity}, + {ItemRequirement::Energy, &ItemRequirements::energy, &ITEM_ATTRIBUTE::RequireEnergy}, + {ItemRequirement::Vitality, &ItemRequirements::vitality, &ITEM_ATTRIBUTE::RequireVitality}, +}; + +// 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 == 15 && definition.requirements.level > 0; + const long long basis = fromLevelRequirement ? definition.requirements.level : definition.level; + const long long percent = energy ? 4 : 3; + return static_cast(std::min(20 + weight * basis * percent / 100, MaxRequirement)); +} +} // namespace + +bool HasRequirementWeightsInBmd(const ItemDefinition& definition) +{ + return definition.slot == ItemSlot::None && (definition.group == 12 || definition.group == 15); +} + +void ToRequirementValues(ItemDefinition& definition) +{ + for (const GrownRequirement& grown : GrownRequirements) + { + WORD& value = definition.requirements.*grown.value; + value = static_cast(GrowWeight(definition, grown.requirement, value)); + } +} + +std::vector ToBmdRequirementWeights(const ItemDefinition& definition, ITEM_ATTRIBUTE& attribute) +{ + std::vector inexact; + for (const GrownRequirement& grown : GrownRequirements) + { + 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..236ac90a70 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,18 @@ using ItemBmdLanguage = LegacyLanguage; // provides the stats. std::span GetItemBmdLanguages(); +// The orbs, crystals and scrolls (items without a slot in groups 12 and 15) keep their stat +// requirements as the values the game asks for, as OpenMU does. 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. +bool HasRequirementWeightsInBmd(const ItemDefinition& definition); +// Import from bmd: the weights of such an item become the values. +void ToRequirementValues(ItemDefinition& definition); +// Export as bmd: the smallest weight that gives each value of such an item, written into +// `attribute`. Returns the requirements no weight gives exactly; they get the nearest one. +std::vector ToBmdRequirementWeights(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..52b7e8b3ea 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,40 @@ 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) +{ + if (!HasRequirementWeightsInBmd(definition)) + { + return; + } + for (const ItemRequirement requirement : ToBmdRequirementWeights(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). @@ -748,9 +784,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 +798,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..3d2e64f830 100644 --- a/src/source/Data/DataHandler/ItemData/ItemDataHandler.h +++ b/src/source/Data/DataHandler/ItemData/ItemDataHandler.h @@ -184,8 +184,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 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..6f5b88f100 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" @@ -1073,6 +1074,51 @@ 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; 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]") +{ + // Healing Orb: 20 + weight * drop level * 4 / 100. + ItemDefinition orb; + orb.group = 12; + orb.number = 8; + orb.level = 8; + orb.requirements.energy = 100; + REQUIRE(HasRequirementWeightsInBmd(orb)); + ToRequirementValues(orb); + CHECK(orb.requirements.energy == 52); + ITEM_ATTRIBUTE attribute{}; + CHECK(ToBmdRequirementWeights(orb, attribute).empty()); + CHECK(attribute.RequireEnergy == 100); + + // 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.requirements.level = 220; + scroll.requirements.energy = 118; + ToRequirementValues(scroll); + CHECK(scroll.requirements.level == 220); + CHECK(scroll.requirements.energy == 1058); + CHECK(ToBmdRequirementWeights(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(steps, attribute); + CHECK(inexact == std::vector{ItemRequirement::Energy}); + CHECK(attribute.RequireEnergy == 2); + + // An item with a slot keeps its weights in both. + ItemDefinition kris; + kris.group = 0; + kris.slot = ItemSlot::MainHand; + CHECK_FALSE(HasRequirementWeightsInBmd(kris)); +} + TEST_CASE("Bmd import reads Portuguese and Spanish names as Windows-1252 [data][items]") { TemporaryClientFolder folder; @@ -1805,6 +1851,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(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; @@ -3729,7 +3821,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 +3832,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_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_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)); From 18b3bcd10d47d58389626fcde6a65d1cf95dd74a Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 22:34:43 +0200 Subject: [PATCH 03/32] feat(items): the item exchange file with OpenMU The file of phase 7: a header (format version, source, the server of a file from OpenMU, game version), the item formulas and the items, with the item fields OpenMU has too in the names and meanings of the item files. The formulas are the item stat file without its profile rules, which stay with the client; each item names the profile it uses. The file is written in the layout of the item files and the item stat file, so the same data always gives the same text. The reader refuses another game version or source and a newer format, reads no item whose (group, number) is there twice (OpenMU does not keep it unique), and leaves out what stays with the client with a warning. The item files and the item stat file give their item and formulas JSON and their text layout to it; their own files stay as they were. --- .../GameData/ItemData/ItemExchangeFile.cpp | 312 ++++++++++++++++++ .../Data/GameData/ItemData/ItemExchangeFile.h | 68 ++++ .../Data/GameData/ItemData/ItemJsonFormat.cpp | 28 +- .../Data/GameData/ItemData/ItemJsonFormat.h | 11 + .../Data/GameData/ItemData/ItemStatsJson.cpp | 40 ++- .../Data/GameData/ItemData/ItemStatsJson.h | 12 + tests/data/CMakeLists.txt | 1 + tests/data/test_item_exchange_file.cpp | 207 ++++++++++++ 8 files changed, 668 insertions(+), 11 deletions(-) create mode 100644 src/source/Data/GameData/ItemData/ItemExchangeFile.cpp create mode 100644 src/source/Data/GameData/ItemData/ItemExchangeFile.h create mode 100644 tests/data/test_item_exchange_file.cpp diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp new file mode 100644 index 0000000000..67a9b1cf10 --- /dev/null +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp @@ -0,0 +1,312 @@ +#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}; + +// The tags OpenMU has too: IsAmmunition and IsQuestItem. +constexpr ItemTag SharedTags[] = {ItemTag::Ammunition, ItemTag::SecondClassQuestItem, ItemTag::ThirdClassQuestItem}; + +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() || field->get_ref().empty()) + { + 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"); + } + } + 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 + +ItemDefinition ToExchangeItem(const ItemDefinition& item) +{ + ItemDefinition shared; + shared.group = item.group; + shared.number = item.number; + shared.names = item.names; + for (const ItemTag tag : SharedTags) + { + shared.tags.Set(tag, item.tags.Has(tag)); + } + shared.width = item.width; + shared.height = item.height; + shared.slot = item.slot; + shared.statProfile = item.statProfile.empty() ? std::string(StatProfileNames::Default) : item.statProfile; + shared.skill = item.skill; + shared.level = item.level; + shared.durability = item.durability; + shared.magicDurability = item.magicDurability; + shared.damageMin = item.damageMin; + shared.damageMax = item.damageMax; + shared.blockRate = item.blockRate; + shared.defense = item.defense; + shared.attackSpeed = item.attackSpeed; + shared.walkSpeed = item.walkSpeed; + shared.magicPower = item.magicPower; + shared.excellentBonus = item.excellentBonus; + shared.excellentRaisesLevel = item.excellentRaisesLevel; + shared.requirements = item.requirements; + shared.requirementsByLevel = item.requirementsByLevel; + shared.classRequirements = item.classRequirements; + shared.resistances = item.resistances; + shared.sellValue = item.sellValue; + shared.buyPrice = item.buyPrice; + shared.tradable = item.tradable; + shared.droppable = item.droppable; + shared.storable = item.storable; + shared.sellable = item.sellable; + shared.personalShopSellable = item.personalShopSellable; + shared.repairable = item.repairable; + 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"; +} +} // 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..5577d1ddba --- /dev/null +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.h @@ -0,0 +1,68 @@ +#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 + +// 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; +}; + +// 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. A (group, number) that is there twice is an error, and neither item is read. 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); + +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..736bd783bc 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -110,6 +110,7 @@ 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 diff --git a/tests/data/test_item_exchange_file.cpp b/tests/data/test_item_exchange_file.cpp new file mode 100644 index 0000000000..937b68672e --- /dev/null +++ b/tests/data/test_item_exchange_file.cpp @@ -0,0 +1,207 @@ +#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("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()); + } + 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); + } +} + +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("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 From 600b4fc5ddc3d698a583a14c7498387729fe133d Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 22:41:01 +0200 Subject: [PATCH 04/32] feat(editor): the diff of the item sync with OpenMU The diff the Item Sync window shows: the client's items and item formulas against an exchange file, field by field, named by their keys in the item files and the item stat file, and the side that changes (the client for an import, the file for an export) with the checked rows taken from the other. New and changed rows are checked by default, removals are not, nor are the known differences (Data/Items/Sync/KnownDifferences.json), a drop level OpenMU cannot keep, or the size and slot of an item the game holds, which stay as they are also when checked. A language one side lacks stays as it is, and an import takes names only in the client's UI languages. A profile one side lacks is one row as a whole; formulas the item stat file's checks refuse are not taken. --- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 699 +++++++++++++++++++++ src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 138 ++++ tests/data/CMakeLists.txt | 1 + tests/data/test_item_sync_diff.cpp | 362 +++++++++++ 4 files changed, 1200 insertions(+) create mode 100644 src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp create mode 100644 src/MuEditor/UI/ItemTools/ItemSyncDiff.h create mode 100644 tests/data/test_item_sync_diff.cpp diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp new file mode 100644 index 0000000000..4de6461e56 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -0,0 +1,699 @@ +#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 +#include +#include + +namespace MuEditor::Items +{ +namespace +{ +using Data::Items::ItemDataIssue; +using Data::Items::ItemDataIssueSeverity; +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 int KnownDifferencesFormatVersion = 1; +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. +bool TakesPart(const std::string& path, bool incomingHasIt, const SyncOptions& options) +{ + std::string locale; + if (!IsNamePath(path, locale)) + { + return true; + } + if (!incomingHasIt) + { + return false; + } + return options.direction != SyncDirection::Import || + std::ranges::find(options.uiLocales, locale) != options.uiLocales.end(); +} + +std::map IndexItems(const ItemExchangeFile& file) +{ + std::map items; + for (const ItemDefinition& item : file.items) + { + items[{item.group, item.number}] = &item; + } + return items; +} + +bool IsHeld(const SyncOptions& options, const ItemKey& key) +{ + return options.direction == SyncDirection::Import && 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 = "the game holds the item: its size and slot stay"; + } + if (field.field == LevelField && options.direction == SyncDirection::Import && + 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 = "OpenMU keeps the drop level in a byte (at most 255)"; + } + } + 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)}); + } + } + row.checked = !row.known; + return row; + } + if (incoming == nullptr) + { + row.change = SyncChange::Removed; + if (IsHeld(options, key)) + { + row.locked = "the game holds the item: it is not removed"; + } + 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, std::vector& rows) +{ + 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; + } + row.entry = path; + if (currentFields.contains(path)) + { + row.current = PartsOf(currentFields, path); + } + if (incomingFields.contains(path)) + { + row.incoming = PartsOf(incomingFields, path); + } + } + else + { + row.entry = path; + row.current = TextOf(currentFields, path); + row.incoming = TextOf(incomingFields, path); + if (row.current == row.incoming) + { + continue; + } + } + row.known = options.known != nullptr && options.known->Contains(row.entry); + row.checked = !row.known; + rows.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 + +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"; +} + +ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, const SyncOptions& options) +{ + ItemSyncDiff diff; + diff.direction = options.direction; + if (options.direction == SyncDirection::Import) + { + diff.takenLocales.assign(options.uiLocales.begin(), options.uiLocales.end()); + } + const ItemExchangeFile& current = options.direction == SyncDirection::Import ? client : file; + const ItemExchangeFile& incoming = options.direction == SyncDirection::Import ? file : client; + const std::map currentItems = IndexItems(current); + const std::map incomingItems = IndexItems(incoming); + std::set keys; + for (const auto& [key, item] : currentItems) + { + keys.insert(key); + } + for (const auto& [key, item] : incomingItems) + { + keys.insert(key); + } + for (const ItemKey& key : keys) + { + const auto was = currentItems.find(key); + const auto becomes = incomingItems.find(key); + 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)); + } + } + if (current.formulas && incoming.formulas) + { + CompareFormulas(*current.formulas, *incoming.formulas, options, diff.formulas); + } + return diff; +} + +ItemExchangeFile ApplyItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, + const ItemSyncDiff& diff, std::vector& issues) +{ + const bool import = diff.direction == SyncDirection::Import; + ItemExchangeFile target = import ? client : file; + const ItemExchangeFile& source = import ? file : client; + const std::map sourceItems = IndexItems(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) + { + continue; + } + Fields fields; + for (const SyncField& field : row.fields) + { + fields[field.field] = sourceFields.at(field.field); + } + targetItems[key] = UnflattenItem(row.group, row.number, fields, issues); + continue; + } + Fields fields = FlattenItem(targetItems.at(key)); + 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..624a8ac4fb --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -0,0 +1,138 @@ +#pragma once + +#ifdef _EDITOR + +#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 (phase 7): the client's items and item formulas against an +// item exchange file, field by field, and the side that changes with the checked rows taken +// from the other. 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). +namespace MuEditor::Items +{ +// Which side takes the other's values. +enum class SyncDirection : uint8_t +{ + // The file's values into the client's data (Import, Update from server). + Import, + // The client's values into the file (Export). + Export, +}; + +// 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); + +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 incoming one when both have it). + std::string name; + // Changed: the fields that differ; New: the fields of the incoming 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 +{ + SyncDirection direction = SyncDirection::Import; + std::vector items; + std::vector formulas; + // Import: the languages names are taken in (the client's UI languages). + std::vector takenLocales; + + bool IsEmpty() const + { + return items.empty() && formulas.empty(); + } +}; + +struct SyncOptions +{ + SyncDirection direction = SyncDirection::Import; + const KnownDifferences* known = nullptr; + // Import: names in other languages are not taken from a file. + std::span uiLocales; + // Import: an item the game holds keeps its size and slot and is not removed. + std::function isHeld; +}; + +// The differences between the client's data (`client`, BuildItemExchange) and `file`. Checked +// by default: what the incoming side changes; unchecked: removals, known differences, inexact +// and locked fields. A language one side lacks stays as it is on the other. +ItemSyncDiff BuildItemSyncDiff(const Data::Items::ItemExchangeFile& client, const Data::Items::ItemExchangeFile& file, + const SyncOptions& options); + +// The side that changes (the client for an import, the file for an export) with the checked +// rows taken from the other. 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/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 736bd783bc..5ecd51bd6b 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -115,6 +115,7 @@ mu_add_test( 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/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp new file mode 100644 index 0000000000..54bdac13e2 --- /dev/null +++ b/tests/data/test_item_sync_diff.cpp @@ -0,0 +1,362 @@ +#include "stdafx.h" + +#include "doctest.h" + +#ifdef _EDITOR + +#include "ClientDataScope.h" + +#include "UI/ItemTools/ItemSyncDiff.h" + +#include "Data/DataHandler/ItemData/ItemDataHandler.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" + +#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(SyncDirection direction, const KnownDifferences* known = nullptr, + std::function isHeld = {}) const + { + return BuildItemSyncDiff(client, file, {direction, 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; +} +} // namespace + +TEST_CASE("The same data on both sides gives no differences [data][items][editor]") +{ + Sides sides; + CHECK(sides.Diff(SyncDirection::Import).IsEmpty()); + CHECK(sides.Diff(SyncDirection::Export).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(SyncDirection::Import); + 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(SyncDirection::Import).IsEmpty()); + // An export writes the client's Spanish name into the file, and leaves its other one. + const ItemSyncDiff exported = sides.Diff(SyncDirection::Export); + const SyncItem* row = FindRow(exported, KrisType); + REQUIRE(row != nullptr); + REQUIRE(row->fields.size() == 1); + CHECK(row->fields[0].field == "name.es"); +} + +TEST_CASE( + "An item the game holds keeps its size and slot, 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; }); + KnownDifferences known; + known.fields.insert({0, 0, "repairable"}); + + const ItemSyncDiff diff = sides.Diff(SyncDirection::Import, &known, [](int itemType) + { return itemType == KrisType || itemType == ShortSwordType; }); + 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()); + + // 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()); +} + +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(SyncDirection::Import); + 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("An export writes the checked client values into the file [data][items][editor]") +{ + Sides sides; + ItemDefinition& clientKris = Sides::Find(sides.client, KrisType); + clientKris.damageMax = static_cast(clientKris.damageMax + 3); + ItemDefinition extra = clientKris; + extra.number = 101; + sides.file.items.push_back(extra); + + const ItemSyncDiff diff = sides.Diff(SyncDirection::Export); + const SyncItem* row = FindRow(diff, KrisType); + REQUIRE(row != nullptr); + REQUIRE(FindField(*row, "damageMax") != nullptr); + const SyncItem* onlyInFile = FindRow(diff, MakeItemType(0, 101)); + REQUIRE(onlyInFile != nullptr); + CHECK(onlyInFile->change == SyncChange::Removed); + + std::vector issues; + ItemExchangeFile written = ApplyItemSyncDiff(sides.client, sides.file, diff, issues); + CHECK(Sides::Find(written, KrisType).damageMax == clientKris.damageMax); + // The removal is unchecked: the file keeps its item. + CHECK(Sides::Find(written, MakeItemType(0, 101)).Exists()); + CHECK(written.source == ItemExchangeSourceOpenMU); +} + +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(SyncDirection::Import); + 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); +} + +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(SyncDirection::Import); + // 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); +} + +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 From 80d98b18499c4da4a54fd3031b49286d8559f768 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 22:57:15 +0200 Subject: [PATCH 05/32] feat(editor): the Item Sync window, Export and Import of the item exchange file The window lists the differences between the client's items and item formulas and an exchange file, per item and per field. Only the checked rows are applied: an import changes the client's data as one step of the undo history, after the checks of a save pass on the data as it would be; an export writes the client's checked values into the file. Known differences stay unchecked and are kept in Data/Items/Sync/KnownDifferences.json. --- src/Localization/Editor.de.resx | 129 ++++ src/Localization/Editor.en.resx | 129 ++++ src/MuEditor/Core/MuEditorCore.cpp | 5 + src/MuEditor/UI/Common/MuEditorToolWindows.h | 1 + src/MuEditor/UI/Common/MuEditorWindowIds.h | 1 + src/MuEditor/UI/Common/ToolGroups.cpp | 3 +- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 252 ++++++ src/MuEditor/UI/ItemTools/ItemSyncApply.h | 48 ++ src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 25 +- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 6 +- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 721 ++++++++++++++++++ src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 85 +++ .../DataHandler/ItemData/ItemJsonStorage.cpp | 7 + .../DataHandler/ItemData/ItemJsonStorage.h | 5 + tests/data/test_item_database_files.cpp | 2 + tests/data/test_item_sync_diff.cpp | 142 ++++ 16 files changed, 1554 insertions(+), 7 deletions(-) create mode 100644 src/MuEditor/UI/ItemTools/ItemSyncApply.cpp create mode 100644 src/MuEditor/UI/ItemTools/ItemSyncApply.h create mode 100644 src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp create mode 100644 src/MuEditor/UI/ItemTools/MuItemSyncUI.h diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 16bfde8a93..50a1a0b645 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2098,4 +2098,133 @@ 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 übernimmt die angehakten Werte der Datei in die Daten des Clients; Exportieren schreibt die angehakten Werte des Clients in die Datei. Beide zeigen zuerst die Unterschiede. + + + Import aus {0} + + + Export nach {0} + + + Von OpenMU, Server {0} + + + {0} neue, {1} geänderte, {2} entfernte Items; {3} Formeleinträge + + + Keine Unterschiede + + + Alle anhaken + + + Keinen anhaken + + + Ein Feld in allen Items + + + Anhaken + + + Haken entfernen + + + Items ({0}) + + + Formeln ({0}) + + + Neu + + + Geändert + + + Entfernt + + + Änderung + + + Felder + + + Jetzt + + + Aus der Datei + + + Aus dem Client + + + 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 + + + Angehakte Änderungen in die Datei schreiben + + + Items importieren + + + Als ein Schritt des Rückgängig-Verlaufs übernommen + + + Geschrieben nach {0} + + + Die Prüfungen lehnen die Änderung ab; die Zeilen der Items mit Fehlern haben keinen Haken mehr: + + + Alle Formeln + + + Felder von {0} + + + Item-Austauschdateien + + + Die Daten des Clients haben sich seit dem Vergleich geändert: neu vergleichen. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 11e97de596..b0dc32470c 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2119,4 +2119,133 @@ 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 takes the checked values of the file into the client's data; Export writes the checked values of the client into the file. Both show the differences first. + + + Import from {0} + + + Export into {0} + + + From OpenMU, server {0} + + + {0} new, {1} changed, {2} removed items; {3} formula entries + + + No differences + + + Check all + + + Check none + + + A field in all items + + + Check them + + + Uncheck them + + + Items ({0}) + + + Formulas ({0}) + + + New + + + Changed + + + Removed + + + Change + + + Fields + + + Now + + + From the file + + + From the client + + + 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 + + + Write the checked changes into the file + + + Import items + + + Applied as one step of the undo history + + + Written to {0} + + + The checks refuse the change; the rows of the items with errors are unchecked: + + + All formulas + + + Fields of {0} + + + Item exchange files + + + The client's data changed since the comparison: compare again. + 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/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/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp new file mode 100644 index 0000000000..457a595a02 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -0,0 +1,252 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "ItemSyncApply.h" + +#include "ItemEdits.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/ItemModelDatabase.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" + +#include +#include +#include +#include +#include + +namespace MuEditor::Items +{ +namespace +{ +using Data::Items::ItemDataIssue; +using Data::Items::ItemDefinition; +using Data::Items::ItemExchangeFile; +using Data::Items::ItemTag; + +// The tags of the exchange file; the client's other tags stay. +constexpr ItemTag SharedTags[] = {ItemTag::Ammunition, ItemTag::SecondClassQuestItem, ItemTag::ThirdClassQuestItem}; + +// 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; +} + +const ItemDefinition* FindItem(const ItemExchangeFile& file, int group, int number) +{ + const auto item = std::ranges::find_if(file.items, [&](const ItemDefinition& entry) + { return entry.group == group && entry.number == number; }); + return item != file.items.end() ? &*item : nullptr; +} +} // 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; + item.names = exchange.names; + for (const ItemTag tag : SharedTags) + { + item.tags.Set(tag, exchange.tags.Has(tag)); + } + item.width = exchange.width; + item.height = exchange.height; + item.slot = exchange.slot; + item.skill = exchange.skill; + item.level = exchange.level; + item.durability = exchange.durability; + item.magicDurability = exchange.magicDurability; + item.damageMin = exchange.damageMin; + item.damageMax = exchange.damageMax; + item.blockRate = exchange.blockRate; + item.defense = exchange.defense; + item.attackSpeed = exchange.attackSpeed; + item.walkSpeed = exchange.walkSpeed; + item.magicPower = exchange.magicPower; + item.excellentBonus = exchange.excellentBonus; + item.excellentRaisesLevel = exchange.excellentRaisesLevel; + item.requirements = exchange.requirements; + item.requirementsByLevel = exchange.requirementsByLevel; + item.classRequirements = exchange.classRequirements; + item.resistances = exchange.resistances; + item.sellValue = exchange.sellValue; + item.buyPrice = exchange.buyPrice; + item.tradable = exchange.tradable; + item.droppable = exchange.droppable; + item.storable = exchange.storable; + item.sellable = exchange.sellable; + item.personalShopSellable = exchange.personalShopSellable; + item.repairable = exchange.repairable; + 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; +} + +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; + } + + // The formulas; the client's profile rules stay. + std::optional statFile; + if (applied.formulas && client.formulas && !(*applied.formulas == *client.formulas)) + { + statFile = *applied.formulas; + statFile->profileRules = g_ItemDataHandler.GetStatFile().profileRules; + } + const Data::Items::ItemStatProfiles profiles = + statFile ? Data::Items::ItemStatProfiles(*statFile) : g_ItemDatabase.GetStatProfiles(); + + std::vector items; + std::vector removed; + 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()) + { + removed.push_back(itemType); + } + continue; + } + const ItemDefinition* exchange = FindItem(applied, row.group, row.number); + const ItemDefinition* current = g_ItemDatabase.Find(itemType); + if (exchange == nullptr || (current != nullptr && Data::Items::ToExchangeItem(*current) == *exchange)) + { + continue; + } + items.push_back(ToClientItem(current, *exchange, profiles)); + } + + // The checks of a save, on the data as it would be. + 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 : 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 : 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); + } + if (Data::Items::HasErrors(checkIssues)) + { + std::ranges::copy_if(checkIssues, std::back_inserter(issues), [](const ItemDataIssue& issue) + { return issue.severity == Data::Items::ItemDataIssueSeverity::Error; }); + return false; + } + + // One step of the undo history. + 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 (!items.empty()) + { + g_ItemEdits.SetItems(label, items); + } + for (const int itemType : removed) + { + g_ItemEdits.RemoveItem(label, itemType); + } + g_ItemEdits.EndGroup(); + return true; +} + +bool ApplyItemSyncExport(const ItemSyncDiff& diff, const ItemExchangeFile& file, const std::filesystem::path& path, + std::vector& issues) +{ + ItemExchangeFile written = ApplyItemSyncDiff(BuildClientExchange(), file, diff, issues); + if (Data::Items::HasErrors(issues)) + { + return false; + } + written.source = Data::Items::ItemExchangeSourceMuMain; + written.server.clear(); + return Data::Items::WriteItemTextFile(path, Data::Items::WriteItemExchangeJson(written), 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..250a6b47cc --- /dev/null +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.h @@ -0,0 +1,48 @@ +#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 with the checked rows of a diff (phase 7): an import changes +// the client's data as one step of the undo history, an export writes the file. +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); + +// Writes the checked rows of an export into `path` (`file` is what it held). The written file +// comes from the client. +bool ApplyItemSyncExport(const ItemSyncDiff& diff, const Data::Items::ItemExchangeFile& file, + const std::filesystem::path& path, std::vector& issues); +} // namespace MuEditor::Items + +#endif // _EDITOR diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index 4de6461e56..ea8308161a 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -9,6 +9,7 @@ #include "Data/GameData/ItemData/ItemJsonFormat.h" #include "Data/GameData/ItemData/ItemStatsJson.h" #include "Data/GameData/ItemData/ItemType.h" +#include "I18N/All.h" #include #include @@ -159,7 +160,7 @@ void MarkField(SyncField& field, const ItemKey& key, const ItemExchangeFile& inc 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 = "the game holds the item: its size and slot stay"; + field.locked = I18N::Editor::TheItemIsWornOrInAnItemWindowItsSizeAndSlotStayAsTheyAre; } if (field.field == LevelField && options.direction == SyncDirection::Import && incoming.source == Data::Items::ItemExchangeSourceOpenMU) @@ -169,7 +170,7 @@ void MarkField(SyncField& field, const ItemKey& key, const ItemExchangeFile& inc if (client != current.end() && client->second.get() > OpenMUMaxDropLevel && server != incomingFields.end() && server->second.get() == OpenMUMaxDropLevel) { - field.inexact = "OpenMU keeps the drop level in a byte (at most 255)"; + field.inexact = I18N::Editor::OpenMUKeepsTheDropLevelInAByteAtMost255; } } field.checked = !field.known && field.locked.empty() && field.inexact.empty(); @@ -204,7 +205,7 @@ SyncItem CompareItem(const ItemKey& key, const ItemDefinition* current, const It row.change = SyncChange::Removed; if (IsHeld(options, key)) { - row.locked = "the game holds the item: it is not removed"; + row.locked = I18N::Editor::TheItemIsWornOrInAnItemWindowItIsNotMovedOrRemoved; } return row; } @@ -440,11 +441,16 @@ void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items 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; })) + if (!source.formulas || std::ranges::none_of(diff.formulas, [](const SyncFormula& row) { return row.checked; })) { return; } + if (!target.formulas) + { + // Only the row of all of them. + target.formulas = source.formulas; + return; + } const Fields from = FlattenFormulas(*source.formulas); Fields fields = FlattenFormulas(*target.formulas); const auto take = [&](const std::string& path) @@ -617,6 +623,15 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang { CompareFormulas(*current.formulas, *incoming.formulas, options, diff.formulas); } + else if (incoming.formulas) + { + // A file without formulas, e.g. a new one: they come in as a whole. + SyncFormula all; + all.entry = AllFormulasEntry; + all.incoming = ToText(Data::Items::WriteItemStatFormulasJson(*incoming.formulas)); + all.checked = true; + diff.formulas.push_back(std::move(all)); + } return diff; } diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 624a8ac4fb..2b1f4f2b85 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -21,7 +21,8 @@ // from the other. 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). +// "profiles.weapon.requirements.strength"; "profiles.weapon" for a profile as a whole, and +// "formulas" for all of them, into a file that has none). namespace MuEditor::Items { // Which side takes the other's values. @@ -46,6 +47,9 @@ struct KnownDifferences bool operator==(const KnownDifferences& other) const = default; }; +// The entry of all the formulas, for a file that has none (SyncFormula::entry). +inline constexpr char AllFormulasEntry[] = "formulas"; + std::filesystem::path GetKnownDifferencesFile(); // Nothing known when the file is not there. KnownDifferences LoadKnownDifferences(const std::filesystem::path& file, diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp new file mode 100644 index 0000000000..544ce09faa --- /dev/null +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -0,0 +1,721 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "MuItemSyncUI.h" + +#include "ItemHeldByCharacter.h" +#include "ItemSelection.h" +#include "ItemSyncApply.h" +#include "ItemTextInput.h" +#include "ItemToolWindow.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 + +using namespace MuEditor::Items; +using Data::Items::ItemDataIssue; + +namespace +{ +// Pixel widths before the editor UI scale. +constexpr float ChangeColumnWidth = 80.0f; +constexpr float FieldColumnWidth = 220.0f; +constexpr float KnownColumnWidth = 60.0f; +constexpr float ItemListHeight = 260.0f; +constexpr float FieldListHeight = 220.0f; +constexpr const char* NoValue = "-"; +constexpr const char* ExchangeFileName = "ItemExchange.json"; + +constexpr ImVec4 ErrorColor{1.0f, 0.4f, 0.4f, 1.0f}; +constexpr ImVec4 WarningColor{1.0f, 0.8f, 0.3f, 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(); +} + +const char* ChangeName(SyncChange change) +{ + switch (change) + { + case SyncChange::New: + return I18N::Editor::New; + 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; }); +} + +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(); +} + +void SDLCALL OnFileChosen(void*, const char* const* files, int) +{ + // Nothing on an error, an empty list when the dialog was cancelled. + if (files != nullptr && files[0] != nullptr) + { + g_MuItemSyncUI.ChooseFile(files[0]); + } +} +} // 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(); + if (m_diff) + { + ImGui::Separator(); + RenderSummary(); + RenderFieldToggle(); + RenderItems(); + RenderFormulas(); + const char* apply = m_direction == SyncDirection::Import ? I18N::Editor::ApplyTheCheckedChanges + : I18N::Editor::WriteTheCheckedChangesIntoTheFile; + if (ImGui::Button(apply)) + { + Apply(); + } + } + RenderMessages(); + } + ToolWindow::End(); +} + +void CMuItemSyncUI::ChooseFile(std::string path) +{ + const std::scoped_lock lock(m_chosenMutex); + m_chosenPath = std::move(path); +} + +void CMuItemSyncUI::TakeChosenFile() +{ + std::optional path; + { + const std::scoped_lock lock(m_chosenMutex); + path.swap(m_chosenPath); + } + if (path) + { + Compare(m_chosenDirection, ToPath(*path)); + } +} + +void CMuItemSyncUI::OpenFileDialog(SyncDirection direction) +{ + static const SDL_DialogFileFilter Filters[] = {{I18N::Editor::ItemExchangeFiles, "json"}}; + { + const std::scoped_lock lock(m_chosenMutex); + m_chosenDirection = direction; + } + if (direction == SyncDirection::Import) + { + SDL_ShowOpenFileDialog(&OnFileChosen, nullptr, nullptr, Filters, 1, nullptr, false); + } + else + { + SDL_ShowSaveFileDialog(&OnFileChosen, nullptr, nullptr, Filters, 1, ExchangeFileName); + } +} + +void CMuItemSyncUI::RenderButtons() +{ + if (ImGui::Button(I18N::Editor::Import)) + { + OpenFileDialog(SyncDirection::Import); + } + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Export)) + { + OpenFileDialog(SyncDirection::Export); + } + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", I18N::Editor::WhatImportAndExportDo); + ImGui::PopTextWrapPos(); + + // The path 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(m_direction, ToPath(m_path)); + } + ImGui::EndDisabled(); +} + +void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path& path) +{ + m_direction = direction; + m_path = ToText(path); + m_messages.clear(); + m_messagesAreErrors = false; + m_diff.reset(); + m_shownItem = -1; + m_file = {}; + + std::vector issues; + if (const std::optional text = Data::Items::Json::ReadTextFile(path)) + { + ReadItemExchangeJson(*text, m_path, m_file, issues); + } + else if (direction == SyncDirection::Import) + { + issues.push_back({Data::Items::ItemDataIssueSeverity::Error, m_path, ItemDataIssue::NoItem, + ItemDataIssue::NoItem, "", "could not be read"}); + } + for (const ItemDataIssue& issue : issues) + { + m_messages.push_back(issue.ToString()); + } + if (Data::Items::HasErrors(issues)) + { + m_messagesAreErrors = true; + return; + } + + std::vector locales; + for (const char* locale : I18N::GetAvailableLocales()) + { + locales.emplace_back(locale); + } + const SyncOptions options{direction, &m_known, locales, [](int itemType) { return IsHeldByCharacter(itemType); }}; + m_diff = BuildItemSyncDiff(BuildClientExchange(), m_file, options); + m_diffVersion = ToolWindow::GetDataVersion(); +} + +bool CMuItemSyncUI::Apply() +{ + if (!m_diff) + { + return false; + } + std::vector issues; + const bool applied = m_direction == SyncDirection::Import + ? ApplyItemSyncImport(*m_diff, m_file, issues) + : ApplyItemSyncExport(*m_diff, m_file, ToPath(m_path), issues); + if (!applied) + { + m_messages.clear(); + m_messages.push_back(I18N::Editor::TheChecksRefuseTheChange); + for (const ItemDataIssue& issue : issues) + { + m_messages.push_back(issue.ToString()); + // The rows of the items with errors are unchecked. + for (SyncItem& row : m_diff->items) + { + if (row.group == issue.group && row.number == issue.number) + { + SetRowChecked(row, false); + } + } + } + m_messagesAreErrors = true; + return false; + } + const std::string done = m_direction == SyncDirection::Import + ? std::string(I18N::Editor::AppliedAsOneStepOfTheUndoHistory) + : I18N::Format(I18N::Editor::WrittenTo0, {m_path}); + g_MuEditorConsoleUI.LogEditor(done); + // The differences that are left: what was not checked. + Compare(m_direction, ToPath(m_path)); + m_messages.insert(m_messages.begin(), done); + 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() +{ + const char* format = m_direction == SyncDirection::Import ? I18N::Editor::ImportFrom0 : I18N::Editor::ExportInto0; + ImGui::TextUnformatted(I18N::Format(format, {m_path}).c_str()); + if (m_file.source == Data::Items::ItemExchangeSourceOpenMU) + { + ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FromOpenMUServer0, + {m_file.server.empty() ? std::string(NoValue) : m_file.server}) + .c_str()); + } + int added = 0; + int changed = 0; + int removed = 0; + for (const SyncItem& row : m_diff->items) + { + added += row.change == SyncChange::New ? 1 : 0; + changed += row.change == SyncChange::Changed ? 1 : 0; + removed += row.change == SyncChange::Removed ? 1 : 0; + } + if (m_diff->IsEmpty()) + { + ImGui::TextUnformatted(I18N::Editor::NoDifferences); + } + else + { + 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 (ToolWindow::GetDataVersion() != m_diffVersion) + { + ImGui::TextColored(WarningColor, "%s", I18N::Editor::TheClientDataChangedSinceTheComparison); + } + if (ImGui::SmallButton(I18N::Editor::CheckAll)) + { + SetAllChecked(true); + } + ImGui::SameLine(); + if (ImGui::SmallButton(I18N::Editor::CheckNone)) + { + SetAllChecked(false); + } +} + +void CMuItemSyncUI::RenderFieldToggle() +{ + 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); + } + } + } + if (names.empty()) + { + return; + } + if (!names.contains(m_toggleField)) + { + m_toggleField = *names.begin(); + } + ImGui::SetNextItemWidth(Scaled(FieldColumnWidth)); + if (ImGui::BeginCombo(I18N::Editor::AFieldInAllItems, m_toggleField.c_str())) + { + for (const std::string& name : names) + { + 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() +{ + const std::string header = + I18N::Format(I18N::Editor::Items0, {std::to_string(m_diff->items.size())}) + "###SyncItems"; + if (m_diff->items.empty() || !ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + constexpr ImGuiTableFlags Flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; + if (ImGui::BeginTable("##SyncItems", 4, Flags, ImVec2(0.0f, Scaled(ItemListHeight)))) + { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("##Checked", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn(I18N::Editor::Change, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); + ImGui::TableSetupColumn(I18N::Editor::Item); + ImGui::TableSetupColumn(I18N::Editor::Fields, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); + 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(); + 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); + } + } + 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()); + } + if (row.change == SyncChange::New && m_direction == SyncDirection::Import) + { + 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; + if (!ImGui::BeginTable("##SyncFields", changed ? 6 : 3, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) + { + return; + } + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("##Checked", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn(I18N::Editor::Field, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); + if (changed) + { + ImGui::TableSetupColumn(I18N::Editor::Now); + } + ImGui::TableSetupColumn(m_direction == SyncDirection::Import ? I18N::Editor::FromTheFile + : I18N::Editor::FromTheClient); + 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(); + 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(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 std::string header = + I18N::Format(I18N::Editor::Formulas0, {std::to_string(m_diff->formulas.size())}) + "###SyncFormulas"; + if (m_diff->formulas.empty() || !ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) + { + return; + } + constexpr ImGuiTableFlags Flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; + if (!ImGui::BeginTable("##SyncFormulas", 5, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) + { + return; + } + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("##Checked", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn(I18N::Editor::Entry, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); + ImGui::TableSetupColumn(I18N::Editor::Now); + ImGui::TableSetupColumn(m_direction == SyncDirection::Import ? I18N::Editor::FromTheFile + : I18N::Editor::FromTheClient); + 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(); + ImGui::TableNextColumn(); + ImGui::Checkbox("##Checked", &row.checked); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(row.entry == AllFormulasEntry ? I18N::Editor::AllFormulas : row.entry.c_str()); + ImGui::TableNextColumn(); + RenderValue(row.current); + ImGui::TableNextColumn(); + RenderValue(row.incoming); + ImGui::TableNextColumn(); + bool known = row.known; + if (row.entry != AllFormulasEntry && ImGui::Checkbox("##Known", &known)) + { + SetKnownFormula(row.entry, known); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); + } + ImGui::PopID(); + } + ImGui::EndTable(); +} + +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..54a9a1e6d6 --- /dev/null +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -0,0 +1,85 @@ +#pragma once + +#ifdef _EDITOR + +#include "ItemSyncDiff.h" + +#include "Data/GameData/ItemData/ItemDataIssue.h" +#include "Data/GameData/ItemData/ItemExchangeFile.h" + +#include +#include +#include +#include +#include + +// The Item Sync window (phase 7, section 9's Item Sync): Export and Import of the item exchange +// file with OpenMU. Each first lists the differences between the client's items and item +// formulas and the file (ItemSyncDiff); only the checked rows are applied, an import 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. +class CMuItemSyncUI +{ +public: + static CMuItemSyncUI& GetInstance(); + + void Render(bool* open); + + // Compares the client's data with the file at `path` for `direction`, as the buttons do + // after their file dialog. An export into a file that is not there yet compares with nothing. + void Compare(MuEditor::Items::SyncDirection direction, const std::filesystem::path& path); + // Applies the checked rows (the Apply button); false when nothing changed. + bool Apply(); + // Takes the path a file dialog chose, on the dialog's thread; the window compares it in + // its next frame. + void ChooseFile(std::string path); + // The diff shown, for its checkboxes. + std::optional& GetDiff() + { + return m_diff; + } + const std::vector& GetMessages() const + { + return m_messages; + } + +private: + CMuItemSyncUI() = default; + + void TakeChosenFile(); + void SetAllChecked(bool checked); + void OpenFileDialog(MuEditor::Items::SyncDirection direction); + void RenderButtons(); + void RenderSummary(); + void RenderFieldToggle(); + void RenderItems(); + void RenderItemFields(MuEditor::Items::SyncItem& row); + void RenderFormulas(); + void RenderMessages(); + void SetKnown(int group, int number, const std::string& field, bool known); + void SetKnownFormula(const std::string& entry, bool known); + void SaveKnownDifferences(); + + MuEditor::Items::SyncDirection m_direction = MuEditor::Items::SyncDirection::Import; + std::string m_path; + Data::Items::ItemExchangeFile m_file; + std::optional m_diff; + MuEditor::Items::KnownDifferences m_known; + bool m_knownLoaded = false; + // ToolWindow::GetDataVersion() when the diff was made. + long long m_diffVersion = 0; + // The row of m_diff->items whose fields are shown, or -1. + int m_shownItem = -1; + std::string m_toggleField; + std::vector m_messages; + bool m_messagesAreErrors = false; + + // The file dialogs answer on their own thread: the path waits here for the next frame. + std::mutex m_chosenMutex; + std::optional m_chosenPath; + MuEditor::Items::SyncDirection m_chosenDirection = MuEditor::Items::SyncDirection::Import; +}; + +#define g_MuItemSyncUI CMuItemSyncUI::GetInstance() + +#endif // _EDITOR 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/tests/data/test_item_database_files.cpp b/tests/data/test_item_database_files.cpp index 6f5b88f100..1bbced04bb 100644 --- a/tests/data/test_item_database_files.cpp +++ b/tests/data/test_item_database_files.cpp @@ -63,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" @@ -2419,6 +2420,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); diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index 54bdac13e2..62ab5ba660 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -6,10 +6,13 @@ #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" @@ -91,6 +94,40 @@ const SyncFormula* FindFormula(const ItemSyncDiff& diff, const std::string& entr 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]") @@ -337,6 +374,111 @@ TEST_CASE("Formulas the item stat file's checks refuse are not taken [data][item CHECK(*applied.formulas == *sides.client.formulas); } +// 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); + 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(SyncDirection::Import, file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK(g_MuItemSyncUI.GetDiff()->items.size() == 3); + 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"); + // 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_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(SyncDirection::Import, 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 into a file that is not there yet: all items and the formulas. +TEST_CASE("Item Sync exports the client's data into a new file [data][items][editor]") +{ + Sides sides; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + g_MuItemSyncUI.Compare(SyncDirection::Export, file); + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK(g_MuItemSyncUI.GetDiff()->items.size() == sides.client.items.size()); + REQUIRE(g_MuItemSyncUI.GetDiff()->formulas.size() == 1); + CHECK(g_MuItemSyncUI.GetDiff()->formulas[0].entry == AllFormulasEntry); + REQUIRE(g_MuItemSyncUI.Apply()); + + const std::optional text = Data::Items::Json::ReadTextFile(file); + REQUIRE(text); + std::vector issues; + ItemExchangeFile written; + ReadItemExchangeJson(*text, file.generic_string(), written, issues); + CHECK(issues.empty()); + CHECK(written.source == ItemExchangeSourceMuMain); + CHECK(written.items == sides.client.items); + CHECK(written.formulas == sides.client.formulas); + // Nothing is left to export. + REQUIRE(g_MuItemSyncUI.GetDiff()); + CHECK(g_MuItemSyncUI.GetDiff()->IsEmpty()); +} + TEST_CASE("The known differences are written and read back [data][items][editor]") { KnownDifferences known; From ba2212573b16472c2bb571ffa938c2b51ce242c1 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 23:05:22 +0200 Subject: [PATCH 06/32] feat(editor): Item Sync shows what uses a removed item and locks a repeated one A removed row of an import lists what Remove item 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) no longer refuses the whole file: it is a locked row the import leaves as it is, while the rest of the file can be imported. An export still refuses such a file, as writing it would lose both copies. --- src/Localization/Editor.de.resx | 3 ++ src/Localization/Editor.en.resx | 3 ++ src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 19 +++++++++- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 3 +- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 21 ++++++++++- src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 5 ++- .../GameData/ItemData/ItemExchangeFile.cpp | 12 +++++++ .../Data/GameData/ItemData/ItemExchangeFile.h | 11 +++++- tests/data/test_item_exchange_file.cpp | 4 +++ tests/data/test_item_sync_diff.cpp | 35 +++++++++++++++++++ 10 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 50a1a0b645..062877bd0e 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2227,4 +2227,7 @@ 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 + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index b0dc32470c..43c4119c96 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2248,4 +2248,7 @@ 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 + diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index ea8308161a..e875c32a55 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -599,7 +599,13 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang const ItemExchangeFile& incoming = options.direction == SyncDirection::Import ? file : client; const std::map currentItems = IndexItems(current); const std::map incomingItems = IndexItems(incoming); - std::set keys; + // An item an imported file has more than once: an error row that neither side applies. + std::set repeated; + if (options.direction == SyncDirection::Import) + { + repeated.insert(file.repeatedItems.begin(), file.repeatedItems.end()); + } + std::set keys = repeated; for (const auto& [key, item] : currentItems) { keys.insert(key); @@ -611,6 +617,17 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang for (const ItemKey& key : keys) { const auto was = currentItems.find(key); + if (repeated.contains(key)) + { + SyncItem row; + row.group = key.first; + row.number = key.second; + row.change = was != currentItems.end() ? SyncChange::Changed : SyncChange::New; + row.name = was != currentItems.end() ? was->second->names.GetNeutral() : std::string(); + row.locked = I18N::Editor::ItIsInTheFileMoreThanOnce; + diff.items.push_back(std::move(row)); + continue; + } const auto becomes = incomingItems.find(key); SyncItem row = CompareItem(key, was != currentItems.end() ? was->second : nullptr, becomes != incomingItems.end() ? becomes->second : nullptr, incoming, options); diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 2b1f4f2b85..e7b767b68e 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -127,7 +127,8 @@ struct SyncOptions // The differences between the client's data (`client`, BuildItemExchange) and `file`. Checked // by default: what the incoming side changes; unchecked: removals, known differences, inexact -// and locked fields. A language one side lacks stays as it is on the other. +// and locked fields. A language one side lacks stays as it is on the other. An item an imported +// file has more than once (its repeatedItems) is a locked row without fields. ItemSyncDiff BuildItemSyncDiff(const Data::Items::ItemExchangeFile& client, const Data::Items::ItemExchangeFile& file, const SyncOptions& options); diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 544ce09faa..ac246242a1 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -9,6 +9,7 @@ #include "ItemSyncApply.h" #include "ItemTextInput.h" #include "ItemToolWindow.h" +#include "ItemUsageView.h" #include "../MuEditor/Core/MuEditorCore.h" #include "../MuEditor/UI/Common/MuEditorWindowIds.h" @@ -234,6 +235,7 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path m_messagesAreErrors = false; m_diff.reset(); m_shownItem = -1; + m_shownUsage = {}; m_file = {}; std::vector issues; @@ -250,7 +252,10 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path { m_messages.push_back(issue.ToString()); } - if (Data::Items::HasErrors(issues)) + // An item an imported file has twice is a row the diff locks; an export would lose it. + const bool usable = direction == SyncDirection::Import ? Data::Items::CanImportItemExchange(m_file, issues) + : !Data::Items::HasErrors(issues); + if (!usable) { m_messagesAreErrors = true; return; @@ -465,6 +470,8 @@ void CMuItemSyncUI::RenderItems() { g_ItemSelection.Select(itemType); } + const bool removedHere = row.change == SyncChange::Removed && m_direction == SyncDirection::Import; + m_shownUsage = removedHere ? FindItemUsage(itemType) : ItemUsage{}; } if (row.known) { @@ -490,6 +497,18 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) { ImGui::TextColored(WarningColor, "%s", row.locked.c_str()); } + // What Remove item lists before it removes an item. + if (row.change == SyncChange::Removed && m_direction == SyncDirection::Import) + { + 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 (row.change == SyncChange::New && m_direction == SyncDirection::Import) { const auto incoming = std::ranges::find_if(m_file.items, [&](const Data::Items::ItemDefinition& item) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h index 54a9a1e6d6..772fdbebb5 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -3,6 +3,7 @@ #ifdef _EDITOR #include "ItemSyncDiff.h" +#include "ItemUsage.h" #include "Data/GameData/ItemData/ItemDataIssue.h" #include "Data/GameData/ItemData/ItemExchangeFile.h" @@ -68,8 +69,10 @@ class CMuItemSyncUI bool m_knownLoaded = false; // ToolWindow::GetDataVersion() when the diff was made. long long m_diffVersion = 0; - // The row of m_diff->items whose fields are shown, or -1. + // 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; std::string m_toggleField; std::vector m_messages; bool m_messagesAreErrors = false; diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp index 67a9b1cf10..ce074ddf38 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp @@ -151,6 +151,7 @@ void ExchangeReader::ReadItems(const OrderedJson& root, ItemExchangeFile& file) { 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; }); @@ -307,6 +308,17 @@ std::string WriteItemExchangeJson(const ItemExchangeFile& file) 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 index 5577d1ddba..136b6a0488 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.h +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.h @@ -10,6 +10,7 @@ #include #include #include +#include #include // The item exchange file with OpenMU (phase 7, the items design's section 6): the item data @@ -46,6 +47,9 @@ struct ItemExchangeFile 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 item as the exchange file has it: the shared fields, the client-only ones at their @@ -56,12 +60,17 @@ ItemDefinition ToExchangeItem(const ItemDefinition& item); 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. A (group, number) that is there twice is an error, and neither item is read. Fields +// 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 diff --git a/tests/data/test_item_exchange_file.cpp b/tests/data/test_item_exchange_file.cpp index 937b68672e..7dd19cb47b 100644 --- a/tests/data/test_item_exchange_file.cpp +++ b/tests/data/test_item_exchange_file.cpp @@ -149,6 +149,7 @@ TEST_CASE("The exchange file reader refuses what cannot be exchanged [data][item 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") { @@ -157,6 +158,9 @@ TEST_CASE("The exchange file reader refuses what cannot be exchanged [data][item 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)); } } diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index 62ab5ba660..0f73dd8990 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -479,6 +479,41 @@ TEST_CASE("Item Sync exports the client's data into a new file [data][items][edi CHECK(g_MuItemSyncUI.GetDiff()->IsEmpty()); } +// 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. An export does not write +// into such a file, as both copies would be lost. +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(SyncDirection::Import, 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(); + g_MuItemSyncUI.Compare(SyncDirection::Export, file); + CHECK_FALSE(g_MuItemSyncUI.GetDiff()); +} + TEST_CASE("The known differences are written and read back [data][items][editor]") { KnownDifferences known; From 9e682a73ff41af011967dac9f4787a592291378e Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 23:09:27 +0200 Subject: [PATCH 07/32] fix(editor): the Known tooltip of Item Sync's formulas only over its box --- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index ac246242a1..84c1fa5a68 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -642,14 +642,18 @@ void CMuItemSyncUI::RenderFormulas() ImGui::TableNextColumn(); RenderValue(row.incoming); ImGui::TableNextColumn(); - bool known = row.known; - if (row.entry != AllFormulasEntry && ImGui::Checkbox("##Known", &known)) - { - SetKnownFormula(row.entry, known); - } - if (ImGui::IsItemHovered()) + // All formulas come in at once into a file without them; they are not a known difference. + if (row.entry != AllFormulasEntry) { - ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); + bool known = row.known; + if (ImGui::Checkbox("##Known", &known)) + { + SetKnownFormula(row.entry, known); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); + } } ImGui::PopID(); } From 83e1dac83f0de0c92aa0c8889b99a090b8f11655 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 23:09:27 +0200 Subject: [PATCH 08/32] docs(items): Item Sync in the item data docs and the editor README The window, the exchange file, the diff and the known differences in docs/item-data.md; the window in the editor README. The design doc now says that an export does not write into a file that has an item twice. --- docs/item-data.md | 135 +++++++++++++++++- .../2026-09-25-data-driven-items-design.md | 3 +- src/MuEditor/README.md | 3 + 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index 84cb7e9eb8..b5a89bff44 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. @@ -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,133 @@ 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 data. + **Apply the checked changes** takes the checked rows into the client as + one step of the undo history; Save Items writes the files, as for any + edit. +- **Export…** picks the file to write and lists how the client's data + differs from what the file holds (all of it for a new file). **Write the + checked changes into the file** writes the checked rows; for the other + rows the file keeps what it has. The written file says `"source": + "MuMain"`. +- The path can also be typed into **Exchange file**; **Compare again** + compares with it. After applying, the window compares again and lists + what is left. When the client's data changes after a comparison, the + window says so. + +**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 + 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. + +**The diff**: + +- The **Items** list has the new, changed and removed items. The box of a + new 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 incoming one, 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. Into a file without formulas they all come in at once. +- Checked at first: what the incoming side changes. Unchecked: removals, + 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), 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**: an import takes only the client's UI languages 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). +- **New 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. An export + does not write into such a file. +- Before an 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 a new +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 d3b702a073..e28bff1af1 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 @@ -394,7 +394,8 @@ Each field belongs to one of two groups: 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 (an error row; neither side applies it). + server (a locked row of the import, which neither side applies; an + export does not write into such a file, as it would lose both). - 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 diff --git a/src/MuEditor/README.md b/src/MuEditor/README.md index 491b45c5f5..8c54b94d7e 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**: Export and Import of the items and item formulas with an OpenMU server through a file, with 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** - Export and Import of the item exchange file with OpenMU: the diff of the items and formulas with a box per field (ItemSyncDiff, which also keeps the known differences), applied as one undo step or written into the file (ItemSyncApply) - **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 From 07fa8d72ff2d92fc71ffce7d5612c20b13df974d Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 23:44:23 +0200 Subject: [PATCH 09/32] fix(editor): Item Sync says why a diff is empty and how to get one Right after an export the file has the client's data, so the window showed only "No differences" with Check all, Check none and the write button, which do nothing then. It now says that the file has the same items and formulas as the client and how to see differences, and hides the buttons. --- src/Localization/Editor.de.resx | 5 +++- src/Localization/Editor.en.resx | 5 +++- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 27 ++++++++++++++++------ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 062877bd0e..8aff70c0da 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2129,7 +2129,7 @@ {0} neue, {1} geänderte, {2} entfernte Items; {3} Formeleinträge - Keine Unterschiede + Keine Unterschiede: Die Datei hat dieselben Items und Formeln wie der Client. Alle anhaken @@ -2230,4 +2230,7 @@ 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. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 43c4119c96..6dd0fb317b 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2150,7 +2150,7 @@ {0} new, {1} changed, {2} removed items; {3} formula entries - No differences + No differences: the file has the same items and formulas as the client. Check all @@ -2251,4 +2251,7 @@ 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. + diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 84c1fa5a68..712f183ceb 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -149,14 +149,19 @@ void CMuItemSyncUI::Render(bool* open) { ImGui::Separator(); RenderSummary(); - RenderFieldToggle(); - RenderItems(); - RenderFormulas(); - const char* apply = m_direction == SyncDirection::Import ? I18N::Editor::ApplyTheCheckedChanges - : I18N::Editor::WriteTheCheckedChangesIntoTheFile; - if (ImGui::Button(apply)) + // Nothing to check or apply while both sides have the same data. + if (!m_diff->IsEmpty()) { - Apply(); + RenderFieldToggle(); + RenderItems(); + RenderFormulas(); + const char* apply = m_direction == SyncDirection::Import + ? I18N::Editor::ApplyTheCheckedChanges + : I18N::Editor::WriteTheCheckedChangesIntoTheFile; + if (ImGui::Button(apply)) + { + Apply(); + } } } RenderMessages(); @@ -343,7 +348,11 @@ void CMuItemSyncUI::RenderSummary() } if (m_diff->IsEmpty()) { + // E.g. right after an export: the file now has what the client has. + ImGui::PushTextWrapPos(0.0f); ImGui::TextUnformatted(I18N::Editor::NoDifferences); + ImGui::TextDisabled("%s", I18N::Editor::HowToSeeDifferences); + ImGui::PopTextWrapPos(); } else { @@ -356,6 +365,10 @@ void CMuItemSyncUI::RenderSummary() { ImGui::TextColored(WarningColor, "%s", I18N::Editor::TheClientDataChangedSinceTheComparison); } + if (m_diff->IsEmpty()) + { + return; + } if (ImGui::SmallButton(I18N::Editor::CheckAll)) { SetAllChecked(true); From 257797008de1e96e8df059016fc591f0b6e13d37 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 22 Oct 2026 23:44:32 +0200 Subject: [PATCH 10/32] docs(items): Item Sync lists nothing right after an export --- docs/item-data.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index b5a89bff44..0efa430a62 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1081,8 +1081,9 @@ Update from server, which downloads it, are later steps of phase 7; see the "MuMain"`. - The path can also be typed into **Exchange file**; **Compare again** compares with it. After applying, the window compares again and lists - what is left. When the client's data changes after a comparison, the - window says so. + what is left: right after an export nothing, as the file then has the + client's data (change an item or the file to see differences again). + When the client's data changes after a comparison, the window says so. **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. From b302eba69ad1824d593f389d76b4949ab2e79cad Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 01:01:19 +0200 Subject: [PATCH 11/32] feat(editor): Item Sync says what it compared, what it applied and why it cannot The window lists only what differs, which left it empty and unclear right after an export. Now: - A file that matches the client says so in green, and the lists say how much of what was compared differs ("Items that differ (3 of 911)", "Items: all 911 are the same in the client and the file"). - What the last Apply wrote or applied shows above what it left; the rows that were not checked stay unchecked. - The apply and write button is off, with the reason, while nothing is checked, while the path field holds another file than the compared one (it wrote into the typed path before), and while the client's items or formulas changed since the comparison. - An export into a file that is there but cannot be read is refused; a new file says it is not there yet. - A new item of a type the character holds is a locked row, and an import is refused when an item it would add, resize, give another slot or remove is held by then (ItemEdits refused it silently, and the import reported success). - A new row whose client data lost a field since the comparison no longer throws. --- docs/item-data.md | 41 ++- .../2026-09-25-data-driven-items-design.md | 4 +- src/Localization/Editor.de.resx | 71 ++++- src/Localization/Editor.en.resx | 71 ++++- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 33 ++ src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 33 +- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 7 + src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 286 +++++++++++++++--- src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 30 +- tests/data/test_item_sync_diff.cpp | 131 +++++++- 10 files changed, 611 insertions(+), 96 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index 0efa430a62..ca2a555445 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1075,15 +1075,32 @@ Update from server, which downloads it, are later steps of phase 7; see the one step of the undo history; Save Items writes the files, as for any edit. - **Export…** picks the file to write and lists how the client's data - differs from what the file holds (all of it for a new file). **Write the - checked changes into the file** writes the checked rows; for the other - rows the file keeps what it has. The written file says `"source": - "MuMain"`. + differs from what the file holds (all of it for a new file, which the + window says is not there yet). **Write the checked changes into the + file** writes the checked rows; for the other rows the file keeps what it + has. The written file says `"source": "MuMain"`. A file that is there but + cannot be read is not written over. +- Only what differs is listed. 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. right after an + export; 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 only one side has is not compared (no language is + removed), nor on an import one in a language the client's UI does not + show. +- After applying, the window compares again: what was applied no longer + differs and leaves the list. A green line above says what was written or + applied ("Written: 911 items and all formulas. What was written no longer + differs."); 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 can also be typed into **Exchange file**; **Compare again** - compares with it. After applying, the window compares again and lists - what is left: right after an export nothing, as the file then has the - client's data (change an item or the file to see differences again). - When the client's data changes after a comparison, the window says so. + compares with it. The apply or write button 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. @@ -1137,12 +1154,14 @@ number, fields with their default value left out, LF line endings. - 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. Into a file without formulas they all come in at once. + has. Into a file without formulas they all come in at once; an import of + a file without formulas compares none and leaves the client's as they + are. - Checked at first: what the incoming side changes. Unchecked: removals, 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), and a drop level above 255, which OpenMU - keeps in a byte. + 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**: an import takes only the client's UI languages from the file; 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 e28bff1af1..b47a6917f0 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 @@ -460,7 +460,9 @@ tool of section 9): 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. + 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:** 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 diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 8aff70c0da..c64232e888 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2114,7 +2114,7 @@ Neu vergleichen - Importieren übernimmt die angehakten Werte der Datei in die Daten des Clients; Exportieren schreibt die angehakten Werte des Clients in die Datei. Beide zeigen zuerst die Unterschiede. + Importieren übernimmt die angehakten Werte der Datei in die Daten des Clients; Exportieren schreibt die angehakten Werte des Clients in die Datei. Beide listen nur auf, was sich unterscheidet: Was auf beiden Seiten gleich ist, fehlt in der Liste. Import aus {0} @@ -2128,9 +2128,6 @@ {0} neue, {1} geänderte, {2} entfernte Items; {3} Formeleinträge - - Keine Unterschiede: Die Datei hat dieselben Items und Formeln wie der Client. - Alle anhaken @@ -2146,12 +2143,6 @@ Haken entfernen - - Items ({0}) - - - Formeln ({0}) - Neu @@ -2206,12 +2197,6 @@ Items importieren - - Als ein Schritt des Rückgängig-Verlaufs übernommen - - - Geschrieben nach {0} - Die Prüfungen lehnen die Änderung ab; die Zeilen der Items mit Fehlern haben keinen Haken mehr: @@ -2233,4 +2218,58 @@ 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}) + + + Formeln mit Unterschieden (alle: die Datei hat keine) + + + 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. + + + Geschrieben: {0} Items und alle Formeln. Was geschrieben wurde, unterscheidet sich nicht mehr. + + + Geschrieben: {0} Items und {1} Formeleinträge. Was geschrieben wurde, unterscheidet sich nicht mehr. + + + 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 Schreiben vergleicht das Fenster neu: Was geschrieben wurde, unterscheidet sich nicht mehr und verschwindet aus der Liste. + + + 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. + + + Die Datei gibt es noch nicht: Schreiben legt sie an. + + + Das Item wird getragen oder ist in einem Item-Fenster: Es wird nicht hinzugefügt, solange das so ist. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 6dd0fb317b..45a1ead0a5 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2135,7 +2135,7 @@ Compare again - Import takes the checked values of the file into the client's data; Export writes the checked values of the client into the file. Both show the differences first. + Import takes the checked values of the file into the client's data; Export writes the checked values of the client into the file. Both list only what differs: what is the same on both sides is left out. Import from {0} @@ -2149,9 +2149,6 @@ {0} new, {1} changed, {2} removed items; {3} formula entries - - No differences: the file has the same items and formulas as the client. - Check all @@ -2167,12 +2164,6 @@ Uncheck them - - Items ({0}) - - - Formulas ({0}) - New @@ -2227,12 +2218,6 @@ Import items - - Applied as one step of the undo history - - - Written to {0} - The checks refuse the change; the rows of the items with errors are unchecked: @@ -2254,4 +2239,58 @@ 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}) + + + Formulas that differ (all: the file has none) + + + 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. + + + Written: {0} items and all formulas. What was written no longer differs. + + + Written: {0} items and {1} formula entries. What was written no longer differs. + + + 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 writing, the window compares again: what was written no longer differs and leaves the list. + + + 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 file is not there yet: writing creates it. + + + The item is worn or in an item window: it is not added while it is. + diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp index 457a595a02..f46ce8184c 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -4,7 +4,9 @@ #include "ItemSyncApply.h" +#include "ItemEditHistory.h" #include "ItemEdits.h" +#include "ItemHeldByCharacter.h" #include "ItemStatFileChange.h" #include "Data/DataHandler/ItemData/ItemDataHandler.h" @@ -172,6 +174,37 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, items.push_back(ToClientItem(current, *exchange, profiles)); } + // 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. + std::vector heldIssues; + for (const ItemDefinition& item : 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)) + { + heldIssues.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 : removed) + { + if (IsHeldByCharacter(itemType)) + { + heldIssues.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"}); + } + } + if (!heldIssues.empty()) + { + issues.insert(issues.end(), heldIssues.begin(), heldIssues.end()); + return false; + } + // The checks of a save, on the data as it would be. std::map candidates; for (const ItemDefinition& item : g_ItemDatabase.GetAllSlots()) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index e875c32a55..eeb0da37f8 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -197,7 +197,13 @@ SyncItem CompareItem(const ItemKey& key, const ItemDefinition* current, const It row.fields.push_back({path, std::nullopt, ToText(value)}); } } - row.checked = !row.known; + // 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) @@ -379,7 +385,7 @@ std::string PartsOf(const Fields& fields, const std::string& profile) } void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items::ItemStatFile& incoming, - const SyncOptions& options, std::vector& rows) + const SyncOptions& options, ItemSyncDiff& diff) { const Fields currentFields = FlattenFormulas(current); const Fields incomingFields = FlattenFormulas(incoming); @@ -412,6 +418,7 @@ void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items { continue; } + ++diff.comparedFormulaEntries; row.entry = path; if (currentFields.contains(path)) { @@ -424,17 +431,21 @@ void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items } 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); row.checked = !row.known; - rows.push_back(std::move(row)); + diff.formulas.push_back(std::move(row)); } } @@ -614,6 +625,7 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang { keys.insert(key); } + diff.comparedItems = static_cast(keys.size()); for (const ItemKey& key : keys) { const auto was = currentItems.find(key); @@ -635,10 +647,14 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang { diff.items.push_back(std::move(row)); } + else + { + ++diff.sameItems; + } } if (current.formulas && incoming.formulas) { - CompareFormulas(*current.formulas, *incoming.formulas, options, diff.formulas); + CompareFormulas(*current.formulas, *incoming.formulas, options, diff); } else if (incoming.formulas) { @@ -683,14 +699,19 @@ ItemExchangeFile ApplyItemSyncDiff(const ItemExchangeFile& client, const ItemExc const Fields sourceFields = FlattenItem(*from->second); if (row.change == SyncChange::New) { - if (!row.checked) + if (!row.checked || !row.locked.empty()) { continue; } Fields fields; for (const SyncField& field : row.fields) { - fields[field.field] = sourceFields.at(field.field); + // 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; diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index e7b767b68e..1df388591b 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -108,6 +108,13 @@ struct ItemSyncDiff std::vector formulas; // Import: the languages names are taken in (the client's UI languages). std::vector takenLocales; + // 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 { diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 712f183ceb..d6c196dca4 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -25,6 +25,8 @@ #include #include +#include +#include using namespace MuEditor::Items; using Data::Items::ItemDataIssue; @@ -42,6 +44,8 @@ constexpr const char* ExchangeFileName = "ItemExchange.json"; 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) @@ -93,6 +97,77 @@ bool IsRowChecked(const SyncItem& row) 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)) @@ -145,23 +220,26 @@ void CMuItemSyncUI::Render(bool* open) } TakeChosenFile(); RenderButtons(); + // 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(); + 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_diff->IsEmpty()) { - RenderFieldToggle(); - RenderItems(); - RenderFormulas(); - const char* apply = m_direction == SyncDirection::Import - ? I18N::Editor::ApplyTheCheckedChanges - : I18N::Editor::WriteTheCheckedChangesIntoTheFile; - if (ImGui::Button(apply)) - { - Apply(); - } + RenderApply(); } } RenderMessages(); @@ -236,6 +314,9 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path { m_direction = direction; m_path = ToText(path); + m_comparedPath = m_path; + m_fileIsNew = false; + m_result.clear(); m_messages.clear(); m_messagesAreErrors = false; m_diff.reset(); @@ -244,15 +325,22 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path m_file = {}; std::vector issues; + std::error_code error; + const bool exists = std::filesystem::exists(path, error); if (const std::optional text = Data::Items::Json::ReadTextFile(path)) { ReadItemExchangeJson(*text, m_path, m_file, issues); } - else if (direction == SyncDirection::Import) + else if (direction == SyncDirection::Import || exists) { + // An export does not write over a file it cannot read. issues.push_back({Data::Items::ItemDataIssueSeverity::Error, m_path, ItemDataIssue::NoItem, ItemDataIssue::NoItem, "", "could not be read"}); } + else + { + m_fileIsNew = true; + } for (const ItemDataIssue& issue : issues) { m_messages.push_back(issue.ToString()); @@ -273,21 +361,44 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path } const SyncOptions options{direction, &m_known, locales, [](int itemType) { return IsHeldByCharacter(itemType); }}; m_diff = BuildItemSyncDiff(BuildClientExchange(), m_file, options); - m_diffVersion = ToolWindow::GetDataVersion(); + m_diffVersion = g_ItemDatabase.GetVersion(); +} + +CMuItemSyncUI::ApplyBlocker CMuItemSyncUI::GetApplyBlocker() const +{ + if (m_path != m_comparedPath) + { + return ApplyBlocker::OtherPath; + } + // An export would write the client's data as it is now, not as the diff shows it. + 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; } bool CMuItemSyncUI::Apply() { - if (!m_diff) + if (!m_diff || GetApplyBlocker() != ApplyBlocker::None) { return false; } + // What is applied, for the line that reports it. + const auto items = std::ranges::count_if(m_diff->items, AppliesSomething); + const auto formulaEntries = std::ranges::count_if(m_diff->formulas, &SyncFormula::checked); + const bool allFormulas = std::ranges::any_of(m_diff->formulas, [](const SyncFormula& row) + { return row.checked && row.entry == AllFormulasEntry; }); + std::vector issues; const bool applied = m_direction == SyncDirection::Import ? ApplyItemSyncImport(*m_diff, m_file, issues) - : ApplyItemSyncExport(*m_diff, m_file, ToPath(m_path), issues); + : ApplyItemSyncExport(*m_diff, m_file, ToPath(m_comparedPath), issues); if (!applied) { + m_result.clear(); m_messages.clear(); m_messages.push_back(I18N::Editor::TheChecksRefuseTheChange); for (const ItemDataIssue& issue : issues) @@ -305,13 +416,32 @@ bool CMuItemSyncUI::Apply() m_messagesAreErrors = true; return false; } - const std::string done = m_direction == SyncDirection::Import - ? std::string(I18N::Editor::AppliedAsOneStepOfTheUndoHistory) - : I18N::Format(I18N::Editor::WrittenTo0, {m_path}); - g_MuEditorConsoleUI.LogEditor(done); - // The differences that are left: what was not checked. - Compare(m_direction, ToPath(m_path)); - m_messages.insert(m_messages.begin(), done); + std::string done; + if (m_direction == SyncDirection::Import) + { + done = I18N::Format(I18N::Editor::Applied0ItemsAnd1FormulaEntries, + {std::to_string(items), std::to_string(formulaEntries)}); + } + else if (allFormulas) + { + done = I18N::Format(I18N::Editor::Written0ItemsAndAllFormulas, {std::to_string(items)}); + } + else + { + done = I18N::Format(I18N::Editor::Written0ItemsAnd1FormulaEntries, + {std::to_string(items), std::to_string(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(m_direction, ToPath(path)); + if (m_diff) + { + KeepUnchecked(*m_diff, unchecked); + } + m_result = done; return true; } @@ -330,41 +460,57 @@ void CMuItemSyncUI::SetAllChecked(bool checked) void CMuItemSyncUI::RenderSummary() { const char* format = m_direction == SyncDirection::Import ? I18N::Editor::ImportFrom0 : I18N::Editor::ExportInto0; - ImGui::TextUnformatted(I18N::Format(format, {m_path}).c_str()); + ImGui::TextUnformatted(I18N::Format(format, {m_comparedPath}).c_str()); if (m_file.source == Data::Items::ItemExchangeSourceOpenMU) { ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FromOpenMUServer0, {m_file.server.empty() ? std::string(NoValue) : m_file.server}) .c_str()); } - int added = 0; - int changed = 0; - int removed = 0; - for (const SyncItem& row : m_diff->items) + ImGui::PushTextWrapPos(0.0f); + if (m_fileIsNew) { - added += row.change == SyncChange::New ? 1 : 0; - changed += row.change == SyncChange::Changed ? 1 : 0; - removed += row.change == SyncChange::Removed ? 1 : 0; + ImGui::TextDisabled("%s", I18N::Editor::TheFileIsNotThereYet); + } + // 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()) { - // E.g. right after an export: the file now has what the client has. - ImGui::PushTextWrapPos(0.0f); - ImGui::TextUnformatted(I18N::Editor::NoDifferences); + // E.g. right after an export: the file now has what the client has. 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); - ImGui::PopTextWrapPos(); } else { + if (!m_result.empty()) + { + ImGui::TextDisabled("%s", I18N::Editor::StillListedBelowTheRowsThatWereNotChecked); + } + int added = 0; + int changed = 0; + int removed = 0; + for (const SyncItem& row : m_diff->items) + { + added += row.change == SyncChange::New ? 1 : 0; + changed += row.change == SyncChange::Changed ? 1 : 0; + removed += row.change == SyncChange::Removed ? 1 : 0; + } 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 (ToolWindow::GetDataVersion() != m_diffVersion) + if (g_ItemDatabase.GetVersion() != m_diffVersion) { ImGui::TextColored(WarningColor, "%s", I18N::Editor::TheClientDataChangedSinceTheComparison); } + ImGui::PopTextWrapPos(); if (m_diff->IsEmpty()) { return; @@ -434,9 +580,20 @@ void CMuItemSyncUI::RenderFieldToggle() void CMuItemSyncUI::RenderItems() { + if (m_diff->items.empty()) + { + if (m_diff->comparedItems > 0) + { + ImGui::TextDisabled( + "%s", I18N::Format(I18N::Editor::ItemsAll0AreTheSame, {std::to_string(m_diff->sameItems)}).c_str()); + } + return; + } const std::string header = - I18N::Format(I18N::Editor::Items0, {std::to_string(m_diff->items.size())}) + "###SyncItems"; - if (m_diff->items.empty() || !ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) + 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; } @@ -621,9 +778,32 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) void CMuItemSyncUI::RenderFormulas() { + // A file without formulas: an import leaves the client's as they are, an export writes all + // of them (one row). + if (!m_file.formulas && m_direction == SyncDirection::Import) + { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", I18N::Editor::FormulasNotComparedTheFileHasNone); + ImGui::PopTextWrapPos(); + return; + } + if (m_diff->formulas.empty()) + { + if (m_diff->comparedFormulaEntries > 0) + { + ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FormulaEntriesAll0AreTheSame, + {std::to_string(m_diff->sameFormulaEntries)}) + .c_str()); + } + return; + } const std::string header = - I18N::Format(I18N::Editor::Formulas0, {std::to_string(m_diff->formulas.size())}) + "###SyncFormulas"; - if (m_diff->formulas.empty() || !ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) + (m_file.formulas + ? I18N::Format(I18N::Editor::FormulaEntriesThatDiffer0Of1, + {std::to_string(m_diff->formulas.size()), std::to_string(m_diff->comparedFormulaEntries)}) + : std::string(I18N::Editor::FormulasThatDifferAllTheFileHasNone)) + + "###SyncFormulas"; + if (!ImGui::CollapsingHeader(header.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { return; } @@ -673,6 +853,36 @@ void CMuItemSyncUI::RenderFormulas() ImGui::EndTable(); } +void CMuItemSyncUI::RenderApply() +{ + const ApplyBlocker blocker = GetApplyBlocker(); + const bool import = m_direction == SyncDirection::Import; + ImGui::BeginDisabled(blocker != ApplyBlocker::None); + if (ImGui::Button(import ? I18N::Editor::ApplyTheCheckedChanges : I18N::Editor::WriteTheCheckedChangesIntoTheFile)) + { + Apply(); + } + ImGui::EndDisabled(); + ImGui::PushTextWrapPos(0.0f); + switch (blocker) + { + case ApplyBlocker::None: + ImGui::TextDisabled("%s", import ? I18N::Editor::AfterApplyingTheWindowComparesAgain + : I18N::Editor::AfterWritingTheWindowComparesAgain); + 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::RenderMessages() { for (const std::string& message : m_messages) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h index 772fdbebb5..3be089b7b3 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -29,7 +29,20 @@ class CMuItemSyncUI // Compares the client's data with the file at `path` for `direction`, as the buttons do // after their file dialog. An export into a file that is not there yet compares with nothing. void Compare(MuEditor::Items::SyncDirection direction, const std::filesystem::path& path); - // Applies the checked rows (the Apply button); false when nothing changed. + // 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; + + // Applies the checked rows (the Apply button); false when nothing changed, also while a + // blocker holds. bool Apply(); // Takes the path a file dialog chose, on the dialog's thread; the window compares it in // its next frame. @@ -43,6 +56,11 @@ class CMuItemSyncUI { 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; @@ -56,19 +74,24 @@ class CMuItemSyncUI void RenderItems(); void RenderItemFields(MuEditor::Items::SyncItem& row); void RenderFormulas(); + void RenderApply(); void RenderMessages(); void SetKnown(int group, int number, const std::string& field, bool known); void SetKnownFormula(const std::string& entry, bool known); void SaveKnownDifferences(); MuEditor::Items::SyncDirection m_direction = MuEditor::Items::SyncDirection::Import; + // The path field, and the file the diff is of: Apply uses that one. std::string m_path; + std::string m_comparedPath; + // Export: the file is not there yet, so every item is new. + bool m_fileIsNew = false; Data::Items::ItemExchangeFile m_file; std::optional m_diff; MuEditor::Items::KnownDifferences m_known; bool m_knownLoaded = false; - // ToolWindow::GetDataVersion() when the diff was made. - long long m_diffVersion = 0; + // 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; @@ -76,6 +99,7 @@ class CMuItemSyncUI std::string m_toggleField; std::vector m_messages; bool m_messagesAreErrors = false; + std::string m_result; // The file dialogs answer on their own thread: the path waits here for the next frame. std::mutex m_chosenMutex; diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index 0f73dd8990..b230e19530 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -210,8 +211,8 @@ TEST_CASE("A name in a language the client does not show, or one the file lacks, CHECK(row->fields[0].field == "name.es"); } -TEST_CASE( - "An item the game holds keeps its size and slot, and a known difference stays unchecked [data][items][editor]") +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); @@ -219,11 +220,16 @@ TEST_CASE( 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(SyncDirection::Import, &known, [](int itemType) - { return itemType == KrisType || itemType == ShortSwordType; }); + const ItemSyncDiff diff = + sides.Diff(SyncDirection::Import, &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"); @@ -237,6 +243,11 @@ TEST_CASE( 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; @@ -253,6 +264,8 @@ TEST_CASE( 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]") @@ -461,8 +474,11 @@ TEST_CASE("Item Sync exports the client's data into a new file [data][items][edi g_MuItemSyncUI.Compare(SyncDirection::Export, file); REQUIRE(g_MuItemSyncUI.GetDiff()); CHECK(g_MuItemSyncUI.GetDiff()->items.size() == sides.client.items.size()); + CHECK(g_MuItemSyncUI.GetDiff()->comparedItems == static_cast(sides.client.items.size())); + CHECK(g_MuItemSyncUI.GetDiff()->sameItems == 0); REQUIRE(g_MuItemSyncUI.GetDiff()->formulas.size() == 1); CHECK(g_MuItemSyncUI.GetDiff()->formulas[0].entry == AllFormulasEntry); + CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::None); REQUIRE(g_MuItemSyncUI.Apply()); const std::optional text = Data::Items::Json::ReadTextFile(file); @@ -474,9 +490,114 @@ TEST_CASE("Item Sync exports the client's data into a new file [data][items][edi CHECK(written.source == ItemExchangeSourceMuMain); CHECK(written.items == sides.client.items); CHECK(written.formulas == sides.client.formulas); - // Nothing is left to export. + // Nothing is left to export, and the window says what was written and that all is the same. REQUIRE(g_MuItemSyncUI.GetDiff()); CHECK(g_MuItemSyncUI.GetDiff()->IsEmpty()); + CHECK_FALSE(g_MuItemSyncUI.GetResult().empty()); + CHECK(g_MuItemSyncUI.GetDiff()->sameItems == static_cast(sides.client.items.size())); + CHECK(g_MuItemSyncUI.GetDiff()->comparedFormulaEntries > 0); + CHECK(g_MuItemSyncUI.GetDiff()->sameFormulaEntries == g_MuItemSyncUI.GetDiff()->comparedFormulaEntries); +} + +// The write is refused while nothing is checked, and while the client's data changed since the +// comparison: an export would write the data as it is now, not as the diff shows it. +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(); + ++sides.FileItem(KrisType).width; + TemporaryFolder folder; + const std::filesystem::path file = folder.Path() / "ItemExchange.json"; + WriteFile(file, WriteItemExchangeJson(sides.file)); + const std::optional before = Data::Items::Json::ReadTextFile(file); + REQUIRE(before); + + g_MuItemSyncUI.Compare(SyncDirection::Export, 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(Data::Items::Json::ReadTextFile(file) == before); + 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. +TEST_CASE("Item Sync leaves what was not checked unchecked after an Apply [data][items][editor]") +{ + Sides sides; + ++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(SyncDirection::Export, 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; + } + REQUIRE(g_MuItemSyncUI.Apply()); + + 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()); +} + +// A new item whose client data lost a field after the comparison (e.g. a value set back to its +// default) is written without that field instead of failing. +TEST_CASE("An export takes a new item whose client data lost a field since the comparison [data][items][editor]") +{ + Sides sides; + std::erase_if(sides.file.items, + [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == KrisType; }); + const ItemSyncDiff diff = sides.Diff(SyncDirection::Export); + const SyncItem* row = FindRow(diff, KrisType); + REQUIRE(row != nullptr); + REQUIRE(row->change == SyncChange::New); + REQUIRE(FindField(*row, "durability") != nullptr); + Sides::Find(sides.client, KrisType).durability = 0; + + std::vector issues; + ItemExchangeFile written; + CHECK_NOTHROW(written = ApplyItemSyncDiff(sides.client, sides.file, diff, issues)); + CHECK(Sides::Find(written, KrisType).durability == 0); } // OpenMU does not keep (group, number) unique: an item a file has twice is a locked row that From 3143160da1bbf5f25c406602f5e629b60f4456ec Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 01:12:34 +0200 Subject: [PATCH 12/32] fix(editor): Item Sync's checkbox columns no longer share an ID with row 0 A table header takes the ID of its label under PushID(column), so the column named "##Checked" had the ID of row 0's "##Checked" checkbox under PushID(0), and ImGui reported the conflict. The column has a name of its own now, in all three tables of the window. --- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index d6c196dca4..b6860ad0ed 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -41,6 +41,9 @@ constexpr float ItemListHeight = 260.0f; constexpr float FieldListHeight = 220.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 ImVec4 ErrorColor{1.0f, 0.4f, 0.4f, 1.0f}; constexpr ImVec4 WarningColor{1.0f, 0.8f, 0.3f, 1.0f}; @@ -602,7 +605,7 @@ void CMuItemSyncUI::RenderItems() if (ImGui::BeginTable("##SyncItems", 4, Flags, ImVec2(0.0f, Scaled(ItemListHeight)))) { ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("##Checked", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); ImGui::TableSetupColumn(I18N::Editor::Change, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); ImGui::TableSetupColumn(I18N::Editor::Item); ImGui::TableSetupColumn(I18N::Editor::Fields, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); @@ -715,7 +718,7 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) return; } ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("##Checked", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); ImGui::TableSetupColumn(I18N::Editor::Field, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); if (changed) { @@ -814,7 +817,7 @@ void CMuItemSyncUI::RenderFormulas() return; } ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("##Checked", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); ImGui::TableSetupColumn(I18N::Editor::Entry, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); ImGui::TableSetupColumn(I18N::Editor::Now); ImGui::TableSetupColumn(m_direction == SyncDirection::Import ? I18N::Editor::FromTheFile From aaf8c32314a9f0dd474c93a72253c9a8f3c5015b Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 01:45:19 +0200 Subject: [PATCH 13/32] feat(editor): Item Sync exports without a diff and asks before it applies or replaces An export compared the client with the file it was written into, which listed every item as new for a new file and was not what an export is expected to do; OpenMU's import page and the client's import each show the diff of the side that changes. Now: - Export writes the client's items and formulas: a new file at once, a file that is there after a confirmation with what changes in it (items added, changed and removed, formula entries; or that it cannot be read as an exchange file), and nothing when the file already has the client's data. - Apply the checked changes asks first, with what it adds, changes and removes in the client; what is not checked or not listed stays. - The import table says what happens in the client: Added, Changed, Removed, under "In the client". The diff is only of a file into the client now: SyncDirection, the row of all formulas for a file without them and the export of checked rows are gone. --- docs/item-data.md | 94 ++--- .../2026-09-25-data-driven-items-design.md | 33 +- src/Localization/Editor.de.resx | 79 ++-- src/Localization/Editor.en.resx | 79 ++-- src/MuEditor/README.md | 4 +- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 68 +++- src/MuEditor/UI/ItemTools/ItemSyncApply.h | 36 +- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 50 +-- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 47 +-- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 347 +++++++++++++----- src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 83 ++++- tests/data/test_item_sync_diff.cpp | 198 +++++----- 12 files changed, 706 insertions(+), 412 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index ca2a555445..b4bdb305de 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1070,37 +1070,41 @@ 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 data. - **Apply the checked changes** takes the checked rows into the client as - one step of the undo history; Save Items writes the files, as for any - edit. -- **Export…** picks the file to write and lists how the client's data - differs from what the file holds (all of it for a new file, which the - window says is not there yet). **Write the checked changes into the - file** writes the checked rows; for the other rows the file keeps what it - has. The written file says `"source": "MuMain"`. A file that is there but - cannot be read is not written over. -- Only what differs is listed. 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. right after an - export; 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 only one side has is not compared (no language is - removed), nor on an import one in a language the client's UI does not - show. +- **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"`. +- 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 written or - applied ("Written: 911 items and all formulas. What was written no longer - differs."); 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 can also be typed into **Exchange file**; **Compare again** - compares with it. The apply or write button 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). + 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. @@ -1143,29 +1147,28 @@ number, fields with their default value left out, LF line endings. 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. -**The diff**: +**The diff of an import**: -- The **Items** list has the new, changed and removed items. The box of a - new 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 +- 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 incoming one, and a box - of its own. + `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. Into a file without formulas they all come in at once; an import of - a file without formulas compares none and leaves the client's as they - are. -- Checked at first: what the incoming side changes. Unchecked: removals, + has. A file without formulas compares none and leaves the client's as + they are. +- Checked at first: what the file changes. Unchecked: removals, 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**: an import takes only the client's UI languages from the file; - a language the file lacks stays as it is, and no language is removed. +- **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). @@ -1179,10 +1182,9 @@ number, fields with their default value left out, LF line endings. - **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. An export - does not write into such a file. -- Before an 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 + 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 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 b47a6917f0..9e19709c1f 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 @@ -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, each with a diff the user checks by hand, plus a read-only Update from server in MuEditor (phase 7, section 6). The client never writes to the server. | +| 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. | @@ -330,10 +330,12 @@ Each field belongs to one of two groups: - **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** (new, changed and removed items and formula - entries, field by field) that the user checks by hand; only the checked - rows are applied. The same holds for an export into an existing file and - for OpenMU's import page. +- **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 @@ -418,11 +420,16 @@ nearest one, with a note, when none gives a value exactly). **The Item Sync window** (MuEditor, `_EDITOR` builds only; the Item Sync tool of section 9): -- **Export…**: a save dialog picks the file. The diff compares the client's - data with what the file holds (nothing for a new file); the checked rows - are written into it, the others keep the file's content. +- **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. + 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. - The file dialogs are SDL3's (`SDL_ShowOpenFileDialog`, @@ -463,8 +470,8 @@ tool of section 9): 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:** 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 +- **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 @@ -593,7 +600,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. | -| **Item Sync** (phase 7) | Export, Import and Update from server of the exchange file, each with a diff checked by hand (section 6). | +| **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. @@ -617,7 +624,7 @@ 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 | The exchange file of items and item formulas; MuEditor's Export, Import (step 1) and Update from server (step 4), each with a diff (section 6). The client-only fields of the item files (tags at first, `description`) are left out. | +| 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. | diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index c64232e888..299172e85f 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2114,19 +2114,16 @@ Neu vergleichen - Importieren übernimmt die angehakten Werte der Datei in die Daten des Clients; Exportieren schreibt die angehakten Werte des Clients in die Datei. Beide listen nur auf, was sich unterscheidet: Was auf beiden Seiten gleich ist, fehlt in der Liste. + Importieren listet auf, wie sich eine Datei von den Items und Formeln des Clients unterscheidet, und übernimmt die angehakten Werte in den Client. Exportieren schreibt die Items und Formeln des Clients in eine Datei; die Import-Seite von OpenMU zeigt, was sie dort ändert. Nur Unterschiede werden aufgelistet: Was auf beiden Seiten gleich ist, fehlt in der Liste. Import aus {0} - - Export nach {0} - Von OpenMU, Server {0} - {0} neue, {1} geänderte, {2} entfernte Items; {3} Formeleinträge + {0} hinzugefügte, {1} geänderte, {2} entfernte Items; {3} Formeleinträge Alle anhaken @@ -2143,18 +2140,12 @@ Haken entfernen - - Neu - Geändert Entfernt - - Änderung - Felder @@ -2164,9 +2155,6 @@ Aus der Datei - - Aus dem Client - Hinweis @@ -2191,18 +2179,12 @@ Angehakte Änderungen übernehmen - - Angehakte Änderungen in die Datei schreiben - Items importieren Die Prüfungen lehnen die Änderung ab; die Zeilen der Items mit Fehlern haben keinen Haken mehr: - - Alle Formeln - Felder von {0} @@ -2230,9 +2212,6 @@ Formeleinträge mit Unterschieden ({0} von {1}) - - Formeln mit Unterschieden (alle: die Datei hat keine) - Items: alle {0} sind im Client und in der Datei gleich. @@ -2242,21 +2221,12 @@ Formeln: nicht verglichen, die Datei hat keine. Die Formeln des Clients bleiben, wie sie sind. - - Geschrieben: {0} Items und alle Formeln. Was geschrieben wurde, unterscheidet sich nicht mehr. - - - Geschrieben: {0} Items und {1} Formeleinträge. Was geschrieben wurde, unterscheidet sich nicht mehr. - 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 Schreiben vergleicht das Fenster neu: Was geschrieben wurde, unterscheidet sich nicht mehr und verschwindet aus der Liste. - Nach dem Übernehmen vergleicht das Fenster neu: Was übernommen wurde, unterscheidet sich nicht mehr und verschwindet aus der Liste. @@ -2266,10 +2236,49 @@ Der Pfad im Feld ist nicht die verglichene Datei: neu vergleichen. - - Die Datei gibt es noch nicht: Schreiben legt sie an. - 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. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 45a1ead0a5..d1091f6e99 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2135,19 +2135,16 @@ Compare again - Import takes the checked values of the file into the client's data; Export writes the checked values of the client into the file. Both list only what differs: what is the same on both sides is left out. + Import lists how a file differs from the client's items and formulas and takes the checked values into the client. Export writes the client's items and formulas into a file; OpenMU's import page shows what it changes there. Only what differs is listed: what is the same on both sides is left out. Import from {0} - - Export into {0} - From OpenMU, server {0} - {0} new, {1} changed, {2} removed items; {3} formula entries + {0} added, {1} changed, {2} removed items; {3} formula entries Check all @@ -2164,18 +2161,12 @@ Uncheck them - - New - Changed Removed - - Change - Fields @@ -2185,9 +2176,6 @@ From the file - - From the client - Note @@ -2212,18 +2200,12 @@ Apply the checked changes - - Write the checked changes into the file - Import items The checks refuse the change; the rows of the items with errors are unchecked: - - All formulas - Fields of {0} @@ -2251,9 +2233,6 @@ Formula entries that differ ({0} of {1}) - - Formulas that differ (all: the file has none) - Items: all {0} are the same in the client and the file. @@ -2263,21 +2242,12 @@ Formulas: not compared, the file has none. The client's formulas stay as they are. - - Written: {0} items and all formulas. What was written no longer differs. - - - Written: {0} items and {1} formula entries. What was written no longer differs. - 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 writing, the window compares again: what was written no longer differs and leaves the list. - After applying, the window compares again: what was applied no longer differs and leaves the list. @@ -2287,10 +2257,49 @@ The path in the field is not the compared file: compare again. - - The file is not there yet: writing creates it. - 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}. + diff --git a/src/MuEditor/README.md b/src/MuEditor/README.md index 8c54b94d7e..e0b64ac8d3 100644 --- a/src/MuEditor/README.md +++ b/src/MuEditor/README.md @@ -8,7 +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**: Export and Import of the items and item formulas with an OpenMU server through a file, with a diff whose checked rows are applied (docs/item-data.md, "Item Sync") +- **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** @@ -49,7 +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** - Export and Import of the item exchange file with OpenMU: the diff of the items and formulas with a box per field (ItemSyncDiff, which also keeps the known differences), applied as one undo step or written into the file (ItemSyncApply) +- **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) - **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) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp index f46ce8184c..2d9ef48330 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -14,6 +14,7 @@ #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" @@ -22,6 +23,8 @@ #include #include #include +#include +#include #include namespace MuEditor::Items @@ -267,17 +270,66 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, return true; } -bool ApplyItemSyncExport(const ItemSyncDiff& diff, const ItemExchangeFile& file, const std::filesystem::path& path, - std::vector& issues) +ItemSyncExportPreview PreviewItemSyncExport(const std::filesystem::path& path) { - ItemExchangeFile written = ApplyItemSyncDiff(BuildClientExchange(), file, diff, issues); - if (Data::Items::HasErrors(issues)) + 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 false; + 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(); + std::map, const ItemDefinition*> fileItems; + for (const ItemDefinition& item : file.items) + { + fileItems[{item.group, item.number}] = &item; + } + // 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); } - written.source = Data::Items::ItemExchangeSourceMuMain; - written.server.clear(); - return Data::Items::WriteItemTextFile(path, Data::Items::WriteItemExchangeJson(written), issues) == + preview.removed = static_cast(fileKeys.size()); + if (file.formulas) + { + preview.formulaEntries = static_cast(BuildItemSyncDiff(client, file, SyncOptions{}).formulas.size()); + } + 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 diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.h b/src/MuEditor/UI/ItemTools/ItemSyncApply.h index 250a6b47cc..faf41b7e42 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.h @@ -13,8 +13,9 @@ #include #include -// What the Item Sync window does with the checked rows of a diff (phase 7): an import changes -// the client's data as one step of the undo history, an export writes the file. +// 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. @@ -39,10 +40,33 @@ std::vector DescribeNewItem(const Data::Items::ItemDefinition& exch bool ApplyItemSyncImport(const ItemSyncDiff& diff, const Data::Items::ItemExchangeFile& file, std::vector& issues); -// Writes the checked rows of an export into `path` (`file` is what it held). The written file -// comes from the client. -bool ApplyItemSyncExport(const ItemSyncDiff& diff, const Data::Items::ItemExchangeFile& file, - const std::filesystem::path& path, 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 index eeb0da37f8..f6e55107d7 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -134,8 +134,7 @@ bool TakesPart(const std::string& path, bool incomingHasIt, const SyncOptions& o { return false; } - return options.direction != SyncDirection::Import || - std::ranges::find(options.uiLocales, locale) != options.uiLocales.end(); + return std::ranges::find(options.uiLocales, locale) != options.uiLocales.end(); } std::map IndexItems(const ItemExchangeFile& file) @@ -150,8 +149,7 @@ std::map IndexItems(const ItemExchangeFile& file bool IsHeld(const SyncOptions& options, const ItemKey& key) { - return options.direction == SyncDirection::Import && options.isHeld && - options.isHeld(Data::Items::MakeItemType(key.first, key.second)); + 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, @@ -162,8 +160,7 @@ void MarkField(SyncField& field, const ItemKey& key, const ItemExchangeFile& inc { field.locked = I18N::Editor::TheItemIsWornOrInAnItemWindowItsSizeAndSlotStayAsTheyAre; } - if (field.field == LevelField && options.direction == SyncDirection::Import && - incoming.source == Data::Items::ItemExchangeSourceOpenMU) + if (field.field == LevelField && incoming.source == Data::Items::ItemExchangeSourceOpenMU) { const auto client = current.find(LevelField); const auto server = incomingFields.find(LevelField); @@ -452,16 +449,11 @@ void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items void ApplyFormulas(const ItemExchangeFile& source, ItemExchangeFile& target, const ItemSyncDiff& diff, std::vector& issues) { - if (!source.formulas || std::ranges::none_of(diff.formulas, [](const SyncFormula& row) { return row.checked; })) + if (!source.formulas || !target.formulas || + std::ranges::none_of(diff.formulas, [](const SyncFormula& row) { return row.checked; })) { return; } - if (!target.formulas) - { - // Only the row of all of them. - target.formulas = source.formulas; - return; - } const Fields from = FlattenFormulas(*source.formulas); Fields fields = FlattenFormulas(*target.formulas); const auto take = [&](const std::string& path) @@ -601,21 +593,13 @@ std::string WriteKnownDifferencesJson(const KnownDifferences& known) ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, const SyncOptions& options) { ItemSyncDiff diff; - diff.direction = options.direction; - if (options.direction == SyncDirection::Import) - { - diff.takenLocales.assign(options.uiLocales.begin(), options.uiLocales.end()); - } - const ItemExchangeFile& current = options.direction == SyncDirection::Import ? client : file; - const ItemExchangeFile& incoming = options.direction == SyncDirection::Import ? file : client; + diff.takenLocales.assign(options.uiLocales.begin(), options.uiLocales.end()); + const ItemExchangeFile& current = client; + const ItemExchangeFile& incoming = file; const std::map currentItems = IndexItems(current); const std::map incomingItems = IndexItems(incoming); - // An item an imported file has more than once: an error row that neither side applies. - std::set repeated; - if (options.direction == SyncDirection::Import) - { - repeated.insert(file.repeatedItems.begin(), file.repeatedItems.end()); - } + // An item the file has more than once: an error row that is not applied. + const std::set repeated(file.repeatedItems.begin(), file.repeatedItems.end()); std::set keys = repeated; for (const auto& [key, item] : currentItems) { @@ -656,24 +640,14 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang { CompareFormulas(*current.formulas, *incoming.formulas, options, diff); } - else if (incoming.formulas) - { - // A file without formulas, e.g. a new one: they come in as a whole. - SyncFormula all; - all.entry = AllFormulasEntry; - all.incoming = ToText(Data::Items::WriteItemStatFormulasJson(*incoming.formulas)); - all.checked = true; - diff.formulas.push_back(std::move(all)); - } return diff; } ItemExchangeFile ApplyItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, const ItemSyncDiff& diff, std::vector& issues) { - const bool import = diff.direction == SyncDirection::Import; - ItemExchangeFile target = import ? client : file; - const ItemExchangeFile& source = import ? file : client; + ItemExchangeFile target = client; + const ItemExchangeFile& source = file; const std::map sourceItems = IndexItems(source); std::map targetItems; for (const ItemDefinition& item : target.items) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 1df388591b..3fd42715df 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -16,24 +16,15 @@ #include #include -// The diff of the Item Sync window (phase 7): the client's items and item formulas against an -// item exchange file, field by field, and the side that changes with the checked rows taken -// from the other. The fields are named by their keys in the item files ("width", +// 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, and -// "formulas" for all of them, into a file that has none). +// "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 { -// Which side takes the other's values. -enum class SyncDirection : uint8_t -{ - // The file's values into the client's data (Import, Update from server). - Import, - // The client's values into the file (Export). - Export, -}; - // 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 @@ -47,9 +38,6 @@ struct KnownDifferences bool operator==(const KnownDifferences& other) const = default; }; -// The entry of all the formulas, for a file that has none (SyncFormula::entry). -inline constexpr char AllFormulasEntry[] = "formulas"; - std::filesystem::path GetKnownDifferencesFile(); // Nothing known when the file is not there. KnownDifferences LoadKnownDifferences(const std::filesystem::path& file, @@ -82,9 +70,9 @@ struct SyncItem int group = 0; int number = 0; SyncChange change = SyncChange::Changed; - // The English name of the side that has the item (the incoming one when both have it). + // 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 incoming item. + // 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; @@ -103,10 +91,9 @@ struct SyncFormula struct ItemSyncDiff { - SyncDirection direction = SyncDirection::Import; std::vector items; std::vector formulas; - // Import: the languages names are taken in (the client's UI languages). + // The languages names are taken in (the client's UI languages). std::vector takenLocales; // 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 @@ -124,24 +111,24 @@ struct ItemSyncDiff struct SyncOptions { - SyncDirection direction = SyncDirection::Import; const KnownDifferences* known = nullptr; - // Import: names in other languages are not taken from a file. + // Names in other languages are not taken from the file. std::span uiLocales; - // Import: an item the game holds keeps its size and slot and is not removed. + // 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; }; // The differences between the client's data (`client`, BuildItemExchange) and `file`. Checked -// by default: what the incoming side changes; unchecked: removals, known differences, inexact -// and locked fields. A language one side lacks stays as it is on the other. An item an imported -// file has more than once (its repeatedItems) is a locked row without fields. +// by default: what the file changes; unchecked: removals, known differences, inexact and locked +// fields. A language one side lacks stays as it is on the other. An item the file 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); -// The side that changes (the client for an import, the file for an export) with the checked -// rows taken from the other. Formulas the item stat file's checks refuse are left as they were, -// with their problems in `issues`. +// 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); diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index b6860ad0ed..79f2ed78bc 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -39,11 +39,14 @@ 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}; @@ -67,12 +70,13 @@ 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::New; + return I18N::Editor::Added; case SyncChange::Removed: return I18N::Editor::Removed; default: @@ -223,6 +227,7 @@ void CMuItemSyncUI::Render(bool* open) } TakeChosenFile(); RenderButtons(); + RenderExportResult(); // An Apply whose comparison afterwards could not read the file still says what it did. if (!m_diff && !m_result.empty()) { @@ -246,6 +251,8 @@ void CMuItemSyncUI::Render(bool* open) } } RenderMessages(); + RenderApplyPopup(); + RenderExportPopup(); } ToolWindow::End(); } @@ -259,24 +266,34 @@ void CMuItemSyncUI::ChooseFile(std::string path) void CMuItemSyncUI::TakeChosenFile() { std::optional path; + FileAction action = FileAction::Import; { const std::scoped_lock lock(m_chosenMutex); path.swap(m_chosenPath); + action = m_chosenAction; } - if (path) + if (!path) { - Compare(m_chosenDirection, ToPath(*path)); + return; + } + if (action == FileAction::Export) + { + Export(ToPath(*path)); + } + else + { + Compare(ToPath(*path)); } } -void CMuItemSyncUI::OpenFileDialog(SyncDirection direction) +void CMuItemSyncUI::OpenFileDialog(FileAction action) { static const SDL_DialogFileFilter Filters[] = {{I18N::Editor::ItemExchangeFiles, "json"}}; { const std::scoped_lock lock(m_chosenMutex); - m_chosenDirection = direction; + m_chosenAction = action; } - if (direction == SyncDirection::Import) + if (action == FileAction::Import) { SDL_ShowOpenFileDialog(&OnFileChosen, nullptr, nullptr, Filters, 1, nullptr, false); } @@ -290,35 +307,44 @@ void CMuItemSyncUI::RenderButtons() { if (ImGui::Button(I18N::Editor::Import)) { - OpenFileDialog(SyncDirection::Import); + OpenFileDialog(FileAction::Import); } ImGui::SameLine(); if (ImGui::Button(I18N::Editor::Export)) { - OpenFileDialog(SyncDirection::Export); + OpenFileDialog(FileAction::Export); } ImGui::PushTextWrapPos(0.0f); ImGui::TextDisabled("%s", I18N::Editor::WhatImportAndExportDo); ImGui::PopTextWrapPos(); - // The path can be typed too, e.g. where no file dialog is at hand. + // 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(m_direction, ToPath(m_path)); + Compare(ToPath(m_path)); } ImGui::EndDisabled(); } -void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path& path) +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_direction = direction; m_path = ToText(path); m_comparedPath = m_path; - m_fileIsNew = false; m_result.clear(); m_messages.clear(); m_messagesAreErrors = false; @@ -328,30 +354,21 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path m_file = {}; std::vector issues; - std::error_code error; - const bool exists = std::filesystem::exists(path, error); if (const std::optional text = Data::Items::Json::ReadTextFile(path)) { ReadItemExchangeJson(*text, m_path, m_file, issues); } - else if (direction == SyncDirection::Import || exists) + else { - // An export does not write over a file it cannot read. issues.push_back({Data::Items::ItemDataIssueSeverity::Error, m_path, ItemDataIssue::NoItem, ItemDataIssue::NoItem, "", "could not be read"}); } - else - { - m_fileIsNew = true; - } for (const ItemDataIssue& issue : issues) { m_messages.push_back(issue.ToString()); } - // An item an imported file has twice is a row the diff locks; an export would lose it. - const bool usable = direction == SyncDirection::Import ? Data::Items::CanImportItemExchange(m_file, issues) - : !Data::Items::HasErrors(issues); - if (!usable) + // An item the file has twice is a row the diff locks. + if (!Data::Items::CanImportItemExchange(m_file, issues)) { m_messagesAreErrors = true; return; @@ -362,7 +379,7 @@ void CMuItemSyncUI::Compare(SyncDirection direction, const std::filesystem::path { locales.emplace_back(locale); } - const SyncOptions options{direction, &m_known, locales, [](int itemType) { return IsHeldByCharacter(itemType); }}; + const SyncOptions options{&m_known, locales, [](int itemType) { return IsHeldByCharacter(itemType); }}; m_diff = BuildItemSyncDiff(BuildClientExchange(), m_file, options); m_diffVersion = g_ItemDatabase.GetVersion(); } @@ -373,7 +390,7 @@ CMuItemSyncUI::ApplyBlocker CMuItemSyncUI::GetApplyBlocker() const { return ApplyBlocker::OtherPath; } - // An export would write the client's data as it is now, not as the diff shows it. + // The diff shows the client's data as it was when it was made. if (g_ItemDatabase.GetVersion() != m_diffVersion) { return ApplyBlocker::DataChanged; @@ -383,6 +400,36 @@ CMuItemSyncUI::ApplyBlocker CMuItemSyncUI::GetApplyBlocker() const 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_diff || GetApplyBlocker() != ApplyBlocker::None) @@ -390,16 +437,10 @@ bool CMuItemSyncUI::Apply() return false; } // What is applied, for the line that reports it. - const auto items = std::ranges::count_if(m_diff->items, AppliesSomething); - const auto formulaEntries = std::ranges::count_if(m_diff->formulas, &SyncFormula::checked); - const bool allFormulas = std::ranges::any_of(m_diff->formulas, [](const SyncFormula& row) - { return row.checked && row.entry == AllFormulasEntry; }); + const CheckedChanges changes = CountCheckedChanges(); std::vector issues; - const bool applied = m_direction == SyncDirection::Import - ? ApplyItemSyncImport(*m_diff, m_file, issues) - : ApplyItemSyncExport(*m_diff, m_file, ToPath(m_comparedPath), issues); - if (!applied) + if (!ApplyItemSyncImport(*m_diff, m_file, issues)) { m_result.clear(); m_messages.clear(); @@ -419,27 +460,15 @@ bool CMuItemSyncUI::Apply() m_messagesAreErrors = true; return false; } - std::string done; - if (m_direction == SyncDirection::Import) - { - done = I18N::Format(I18N::Editor::Applied0ItemsAnd1FormulaEntries, - {std::to_string(items), std::to_string(formulaEntries)}); - } - else if (allFormulas) - { - done = I18N::Format(I18N::Editor::Written0ItemsAndAllFormulas, {std::to_string(items)}); - } - else - { - done = I18N::Format(I18N::Editor::Written0ItemsAnd1FormulaEntries, - {std::to_string(items), std::to_string(formulaEntries)}); - } + 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(m_direction, ToPath(path)); + Compare(ToPath(path)); if (m_diff) { KeepUnchecked(*m_diff, unchecked); @@ -448,6 +477,69 @@ bool CMuItemSyncUI::Apply() 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. + m_pendingExport = preview; + m_openExportPopup = true; +} + +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); + // An import comparison of that file shows what it held before. + if (m_diff && m_comparedPath == m_exportPath) + { + Compare(ToPath(m_exportPath)); + } + return true; +} + void CMuItemSyncUI::SetAllChecked(bool checked) { for (SyncItem& row : m_diff->items) @@ -462,8 +554,7 @@ void CMuItemSyncUI::SetAllChecked(bool checked) void CMuItemSyncUI::RenderSummary() { - const char* format = m_direction == SyncDirection::Import ? I18N::Editor::ImportFrom0 : I18N::Editor::ExportInto0; - ImGui::TextUnformatted(I18N::Format(format, {m_comparedPath}).c_str()); + ImGui::TextUnformatted(I18N::Format(I18N::Editor::ImportFrom0, {m_comparedPath}).c_str()); if (m_file.source == Data::Items::ItemExchangeSourceOpenMU) { ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FromOpenMUServer0, @@ -471,10 +562,6 @@ void CMuItemSyncUI::RenderSummary() .c_str()); } ImGui::PushTextWrapPos(0.0f); - if (m_fileIsNew) - { - ImGui::TextDisabled("%s", I18N::Editor::TheFileIsNotThereYet); - } // What the last Apply did comes before what it left. if (!m_result.empty()) { @@ -482,8 +569,7 @@ void CMuItemSyncUI::RenderSummary() } if (m_diff->IsEmpty()) { - // E.g. right after an export: the file now has what the client has. The client always has - // formulas, so they were compared when the file has them too. + // 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); @@ -606,7 +692,7 @@ void CMuItemSyncUI::RenderItems() { ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); - ImGui::TableSetupColumn(I18N::Editor::Change, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); + ImGui::TableSetupColumn(I18N::Editor::InTheClient, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); ImGui::TableSetupColumn(I18N::Editor::Item); ImGui::TableSetupColumn(I18N::Editor::Fields, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); ImGui::TableHeadersRow(); @@ -643,8 +729,7 @@ void CMuItemSyncUI::RenderItems() { g_ItemSelection.Select(itemType); } - const bool removedHere = row.change == SyncChange::Removed && m_direction == SyncDirection::Import; - m_shownUsage = removedHere ? FindItemUsage(itemType) : ItemUsage{}; + m_shownUsage = row.change == SyncChange::Removed ? FindItemUsage(itemType) : ItemUsage{}; } if (row.known) { @@ -671,7 +756,7 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) ImGui::TextColored(WarningColor, "%s", row.locked.c_str()); } // What Remove item lists before it removes an item. - if (row.change == SyncChange::Removed && m_direction == SyncDirection::Import) + if (row.change == SyncChange::Removed) { ImGui::TextUnformatted(I18N::Editor::TheItemAndItsModelEntryAreRemoved); if (m_shownUsage.GoesWithItem()) @@ -682,7 +767,7 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) ImGui::TextUnformatted(I18N::Editor::WhatUsesItStaysAtItsNumber); UsageView::RenderUsesAtNumber(m_shownUsage); } - if (row.change == SyncChange::New && m_direction == SyncDirection::Import) + if (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; }); @@ -724,8 +809,7 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) { ImGui::TableSetupColumn(I18N::Editor::Now); } - ImGui::TableSetupColumn(m_direction == SyncDirection::Import ? I18N::Editor::FromTheFile - : I18N::Editor::FromTheClient); + ImGui::TableSetupColumn(I18N::Editor::FromTheFile); if (changed) { ImGui::TableSetupColumn(I18N::Editor::Note); @@ -781,9 +865,8 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) void CMuItemSyncUI::RenderFormulas() { - // A file without formulas: an import leaves the client's as they are, an export writes all - // of them (one row). - if (!m_file.formulas && m_direction == SyncDirection::Import) + // A file without formulas leaves the client's as they are. + if (!m_file.formulas) { ImGui::PushTextWrapPos(0.0f); ImGui::TextDisabled("%s", I18N::Editor::FormulasNotComparedTheFileHasNone); @@ -801,10 +884,8 @@ void CMuItemSyncUI::RenderFormulas() return; } const std::string header = - (m_file.formulas - ? I18N::Format(I18N::Editor::FormulaEntriesThatDiffer0Of1, - {std::to_string(m_diff->formulas.size()), std::to_string(m_diff->comparedFormulaEntries)}) - : std::string(I18N::Editor::FormulasThatDifferAllTheFileHasNone)) + + 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)) { @@ -820,8 +901,7 @@ void CMuItemSyncUI::RenderFormulas() ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); ImGui::TableSetupColumn(I18N::Editor::Entry, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); ImGui::TableSetupColumn(I18N::Editor::Now); - ImGui::TableSetupColumn(m_direction == SyncDirection::Import ? I18N::Editor::FromTheFile - : I18N::Editor::FromTheClient); + ImGui::TableSetupColumn(I18N::Editor::FromTheFile); ImGui::TableSetupColumn(I18N::Editor::Known, ImGuiTableColumnFlags_WidthFixed, Scaled(KnownColumnWidth)); ImGui::TableHeadersRow(); for (size_t index = 0; index < m_diff->formulas.size(); ++index) @@ -832,24 +912,20 @@ void CMuItemSyncUI::RenderFormulas() ImGui::TableNextColumn(); ImGui::Checkbox("##Checked", &row.checked); ImGui::TableNextColumn(); - ImGui::TextUnformatted(row.entry == AllFormulasEntry ? I18N::Editor::AllFormulas : row.entry.c_str()); + ImGui::TextUnformatted(row.entry.c_str()); ImGui::TableNextColumn(); RenderValue(row.current); ImGui::TableNextColumn(); RenderValue(row.incoming); ImGui::TableNextColumn(); - // All formulas come in at once into a file without them; they are not a known difference. - if (row.entry != AllFormulasEntry) + bool known = row.known; + if (ImGui::Checkbox("##Known", &known)) { - bool known = row.known; - if (ImGui::Checkbox("##Known", &known)) - { - SetKnownFormula(row.entry, known); - } - if (ImGui::IsItemHovered()) - { - ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); - } + SetKnownFormula(row.entry, known); + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("%s", I18N::Editor::WhatAKnownDifferenceIs); } ImGui::PopID(); } @@ -859,19 +935,18 @@ void CMuItemSyncUI::RenderFormulas() void CMuItemSyncUI::RenderApply() { const ApplyBlocker blocker = GetApplyBlocker(); - const bool import = m_direction == SyncDirection::Import; ImGui::BeginDisabled(blocker != ApplyBlocker::None); - if (ImGui::Button(import ? I18N::Editor::ApplyTheCheckedChanges : I18N::Editor::WriteTheCheckedChangesIntoTheFile)) + // It asks first (RenderApplyPopup). + if (ImGui::Button(I18N::Editor::ApplyTheCheckedChanges)) { - Apply(); + m_openApplyPopup = true; } ImGui::EndDisabled(); ImGui::PushTextWrapPos(0.0f); switch (blocker) { case ApplyBlocker::None: - ImGui::TextDisabled("%s", import ? I18N::Editor::AfterApplyingTheWindowComparesAgain - : I18N::Editor::AfterWritingTheWindowComparesAgain); + ImGui::TextDisabled("%s", I18N::Editor::AfterApplyingTheWindowComparesAgain); break; case ApplyBlocker::OtherPath: ImGui::TextColored(WarningColor, "%s", I18N::Editor::ThePathIsNotTheComparedFile); @@ -886,6 +961,94 @@ void CMuItemSyncUI::RenderApply() ImGui::PopTextWrapPos(); } +void CMuItemSyncUI::RenderApplyPopup() +{ + if (m_openApplyPopup) + { + ImGui::OpenPopup(ApplyPopupId); + m_openApplyPopup = false; + } + 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)) + { + Apply(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button(I18N::Editor::Cancel)) + { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void CMuItemSyncUI::RenderExportPopup() +{ + if (m_openExportPopup) + { + ImGui::OpenPopup(ExportPopupId); + m_openExportPopup = false; + } + 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) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h index 3be089b7b3..56cc9382ed 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -2,23 +2,27 @@ #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): Export and Import of the item exchange -// file with OpenMU. Each first lists the differences between the client's items and item -// formulas and the file (ItemSyncDiff); only the checked rows are applied, an import 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. +// 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. class CMuItemSyncUI { public: @@ -26,9 +30,9 @@ class CMuItemSyncUI void Render(bool* open); - // Compares the client's data with the file at `path` for `direction`, as the buttons do - // after their file dialog. An export into a file that is not there yet compares with nothing. - void Compare(MuEditor::Items::SyncDirection direction, const std::filesystem::path& path); + // Compares the client's data with the file at `path`, as Import does after its dialog. + void Compare(const std::filesystem::path& path); + // Why the checked rows cannot be applied now. enum class ApplyBlocker { @@ -41,11 +45,39 @@ class CMuItemSyncUI }; ApplyBlocker GetApplyBlocker() const; - // Applies the checked rows (the Apply button); false when nothing changed, also while a - // blocker holds. + // 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(); - // Takes the path a file dialog chose, on the dialog's thread; the window compares it in - // its next frame. + + // 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; + } + + // Takes the path a file dialog chose, on the dialog's thread; the window uses it in its next + // frame. void ChooseFile(std::string path); // The diff shown, for its checkboxes. std::optional& GetDiff() @@ -63,29 +95,37 @@ class CMuItemSyncUI } private: + // The button whose file dialog answers. + enum class FileAction : uint8_t + { + Import, + Export, + }; + CMuItemSyncUI() = default; void TakeChosenFile(); void SetAllChecked(bool checked); - void OpenFileDialog(MuEditor::Items::SyncDirection direction); + void OpenFileDialog(FileAction action); void RenderButtons(); + 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(); - MuEditor::Items::SyncDirection m_direction = MuEditor::Items::SyncDirection::Import; // The path field, and the file the diff is of: Apply uses that one. std::string m_path; std::string m_comparedPath; - // Export: the file is not there yet, so every item is new. - bool m_fileIsNew = false; Data::Items::ItemExchangeFile m_file; std::optional m_diff; MuEditor::Items::KnownDifferences m_known; @@ -100,11 +140,20 @@ class CMuItemSyncUI std::vector m_messages; bool m_messagesAreErrors = false; std::string m_result; + bool m_openApplyPopup = false; + + // The export: the file it writes, what changes in it while the confirmation waits, and what + // the last one did. + std::string m_exportPath; + std::optional m_pendingExport; + bool m_openExportPopup = false; + std::string m_exportResult; + bool m_exportFailed = false; // The file dialogs answer on their own thread: the path waits here for the next frame. std::mutex m_chosenMutex; std::optional m_chosenPath; - MuEditor::Items::SyncDirection m_chosenDirection = MuEditor::Items::SyncDirection::Import; + FileAction m_chosenAction = FileAction::Import; }; #define g_MuItemSyncUI CMuItemSyncUI::GetInstance() diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index b230e19530..c1f936f99d 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -64,10 +64,9 @@ class Sides return *item; } - ItemSyncDiff Diff(SyncDirection direction, const KnownDifferences* known = nullptr, - std::function isHeld = {}) const + ItemSyncDiff Diff(const KnownDifferences* known = nullptr, std::function isHeld = {}) const { - return BuildItemSyncDiff(client, file, {direction, known, UiLocales, std::move(isHeld)}); + return BuildItemSyncDiff(client, file, {known, UiLocales, std::move(isHeld)}); } ItemExchangeFile client; @@ -134,8 +133,7 @@ void WriteFile(const std::filesystem::path& file, const std::string& text) TEST_CASE("The same data on both sides gives no differences [data][items][editor]") { Sides sides; - CHECK(sides.Diff(SyncDirection::Import).IsEmpty()); - CHECK(sides.Diff(SyncDirection::Export).IsEmpty()); + CHECK(sides.Diff().IsEmpty()); } TEST_CASE( @@ -153,7 +151,7 @@ TEST_CASE( std::erase_if(sides.file.items, [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == ShortSwordType; }); - ItemSyncDiff diff = sides.Diff(SyncDirection::Import); + ItemSyncDiff diff = sides.Diff(); REQUIRE(diff.items.size() == 3); const SyncItem* changed = FindRow(diff, KrisType); REQUIRE(changed != nullptr); @@ -202,13 +200,7 @@ TEST_CASE("A name in a language the client does not show, or one the file lacks, } withoutSpanish.Set("zh-CN", "Kris (zh)"); kris.names = withoutSpanish; - CHECK(sides.Diff(SyncDirection::Import).IsEmpty()); - // An export writes the client's Spanish name into the file, and leaves its other one. - const ItemSyncDiff exported = sides.Diff(SyncDirection::Export); - const SyncItem* row = FindRow(exported, KrisType); - REQUIRE(row != nullptr); - REQUIRE(row->fields.size() == 1); - CHECK(row->fields[0].field == "name.es"); + 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 " @@ -228,7 +220,7 @@ TEST_CASE("An item the game holds keeps its size and slot or is not added, and a known.fields.insert({0, 0, "repairable"}); const ItemSyncDiff diff = - sides.Diff(SyncDirection::Import, &known, [](int itemType) + sides.Diff(&known, [](int itemType) { return itemType == KrisType || itemType == ShortSwordType || itemType == MakeItemType(0, 100); }); const SyncItem* row = FindRow(diff, KrisType); REQUIRE(row != nullptr); @@ -273,7 +265,7 @@ TEST_CASE("A drop level OpenMU cannot keep is not taken by default [data][items] 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(SyncDirection::Import); + const ItemSyncDiff diff = sides.Diff(); const SyncItem* row = FindRow(diff, ITEM_HORN_OF_FENRIR); REQUIRE(row != nullptr); const SyncField* level = FindField(*row, "level"); @@ -282,31 +274,6 @@ TEST_CASE("A drop level OpenMU cannot keep is not taken by default [data][items] CHECK_FALSE(level->checked); } -TEST_CASE("An export writes the checked client values into the file [data][items][editor]") -{ - Sides sides; - ItemDefinition& clientKris = Sides::Find(sides.client, KrisType); - clientKris.damageMax = static_cast(clientKris.damageMax + 3); - ItemDefinition extra = clientKris; - extra.number = 101; - sides.file.items.push_back(extra); - - const ItemSyncDiff diff = sides.Diff(SyncDirection::Export); - const SyncItem* row = FindRow(diff, KrisType); - REQUIRE(row != nullptr); - REQUIRE(FindField(*row, "damageMax") != nullptr); - const SyncItem* onlyInFile = FindRow(diff, MakeItemType(0, 101)); - REQUIRE(onlyInFile != nullptr); - CHECK(onlyInFile->change == SyncChange::Removed); - - std::vector issues; - ItemExchangeFile written = ApplyItemSyncDiff(sides.client, sides.file, diff, issues); - CHECK(Sides::Find(written, KrisType).damageMax == clientKris.damageMax); - // The removal is unchecked: the file keeps its item. - CHECK(Sides::Find(written, MakeItemType(0, 101)).Exists()); - CHECK(written.source == ItemExchangeSourceOpenMU); -} - TEST_CASE("The formulas differ by drop level bonus, table, term and whole profile [data][items][editor]") { Sides sides; @@ -326,7 +293,7 @@ TEST_CASE("The formulas differ by drop level bonus, table, term and whole profil added.requirements.levelPerItemLevel = 2; formulas.profiles.push_back(added); - ItemSyncDiff diff = sides.Diff(SyncDirection::Import); + ItemSyncDiff diff = sides.Diff(); CHECK(diff.items.empty()); REQUIRE(diff.formulas.size() == 4); REQUIRE(FindFormula(diff, "dropLevelBonus.excellent") != nullptr); @@ -375,7 +342,7 @@ TEST_CASE("Formulas the item stat file's checks refuse are not taken [data][item profile.stats[static_cast(ItemStat::Defense)].table = "runeTable"; } } - ItemSyncDiff diff = sides.Diff(SyncDirection::Import); + ItemSyncDiff diff = sides.Diff(); // Only the term, not the table it names. for (SyncFormula& row : diff.formulas) { @@ -408,7 +375,7 @@ TEST_CASE("Item Sync imports the checked rows of a file as one undo step [data][ const std::filesystem::path file = folder.Path() / "ItemExchange.json"; WriteFile(file, WriteItemExchangeJson(sides.file)); - g_MuItemSyncUI.Compare(SyncDirection::Import, file); + g_MuItemSyncUI.Compare(file); REQUIRE(g_MuItemSyncUI.GetDiff()); CHECK(g_MuItemSyncUI.GetDiff()->items.size() == 3); CHECK(g_MuItemSyncUI.GetDiff()->formulas.size() == 1); @@ -453,7 +420,7 @@ TEST_CASE("Item Sync refuses an import the checks of a save refuse [data][items] const std::filesystem::path file = folder.Path() / "ItemExchange.json"; WriteFile(file, WriteItemExchangeJson(sides.file)); - g_MuItemSyncUI.Compare(SyncDirection::Import, file); + g_MuItemSyncUI.Compare(file); REQUIRE(g_MuItemSyncUI.GetDiff()); CHECK_FALSE(g_MuItemSyncUI.Apply()); CHECK(*g_ItemDatabase.Find(KrisType) == kris); @@ -465,54 +432,94 @@ TEST_CASE("Item Sync refuses an import the checks of a save refuse [data][items] CHECK(g_MuItemSyncUI.GetMessages().size() > 1); } -// The window's Export into a file that is not there yet: all items and the formulas. -TEST_CASE("Item Sync exports the client's data into a new file [data][items][editor]") +// 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.Compare(SyncDirection::Export, file); - REQUIRE(g_MuItemSyncUI.GetDiff()); - CHECK(g_MuItemSyncUI.GetDiff()->items.size() == sides.client.items.size()); - CHECK(g_MuItemSyncUI.GetDiff()->comparedItems == static_cast(sides.client.items.size())); - CHECK(g_MuItemSyncUI.GetDiff()->sameItems == 0); - REQUIRE(g_MuItemSyncUI.GetDiff()->formulas.size() == 1); - CHECK(g_MuItemSyncUI.GetDiff()->formulas[0].entry == AllFormulasEntry); - CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::None); - REQUIRE(g_MuItemSyncUI.Apply()); - - const std::optional text = Data::Items::Json::ReadTextFile(file); - REQUIRE(text); + 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(*text, file.generic_string(), written, issues); + 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); - // Nothing is left to export, and the window says what was written and that all is the same. - REQUIRE(g_MuItemSyncUI.GetDiff()); - CHECK(g_MuItemSyncUI.GetDiff()->IsEmpty()); - CHECK_FALSE(g_MuItemSyncUI.GetResult().empty()); - CHECK(g_MuItemSyncUI.GetDiff()->sameItems == static_cast(sides.client.items.size())); - CHECK(g_MuItemSyncUI.GetDiff()->comparedFormulaEntries > 0); - CHECK(g_MuItemSyncUI.GetDiff()->sameFormulaEntries == g_MuItemSyncUI.GetDiff()->comparedFormulaEntries); + + 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); + } } -// The write is refused while nothing is checked, and while the client's data changed since the -// comparison: an export would write the data as it is now, not as the diff shows it. +// 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)); - const std::optional before = Data::Items::Json::ReadTextFile(file); - REQUIRE(before); - g_MuItemSyncUI.Compare(SyncDirection::Export, file); + g_MuItemSyncUI.Compare(file); REQUIRE(g_MuItemSyncUI.GetDiff()); CHECK(g_MuItemSyncUI.GetApplyBlocker() == CMuItemSyncUI::ApplyBlocker::None); SUBCASE("nothing checked") @@ -543,22 +550,25 @@ TEST_CASE("Item Sync applies nothing while nothing is checked or the client's da } CHECK_FALSE(g_MuItemSyncUI.Apply()); CHECK(g_MuItemSyncUI.GetResult().empty()); - CHECK(Data::Items::Json::ReadTextFile(file) == before); + 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. +// 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(SyncDirection::Export, 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) @@ -568,7 +578,13 @@ TEST_CASE("Item Sync leaves what was not checked unchecked after an Apply [data] { 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); @@ -578,31 +594,35 @@ TEST_CASE("Item Sync leaves what was not checked unchecked after an Apply [data] 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 client data lost a field after the comparison (e.g. a value set back to its -// default) is written without that field instead of failing. -TEST_CASE("An export takes a new item whose client data lost a field since the comparison [data][items][editor]") +// 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; - std::erase_if(sides.file.items, - [](const ItemDefinition& item) { return MakeItemType(item.group, item.number) == KrisType; }); - const ItemSyncDiff diff = sides.Diff(SyncDirection::Export); - const SyncItem* row = FindRow(diff, KrisType); + 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.client, KrisType).durability = 0; + Sides::Find(sides.file, addedType).durability = 0; std::vector issues; - ItemExchangeFile written; - CHECK_NOTHROW(written = ApplyItemSyncDiff(sides.client, sides.file, diff, issues)); - CHECK(Sides::Find(written, KrisType).durability == 0); + ItemExchangeFile applied; + CHECK_NOTHROW(applied = ApplyItemSyncDiff(sides.client, sides.file, diff, issues)); + CHECK(Sides::Find(applied, addedType).durability == 0); } // 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. An export does not write -// into such a file, as both copies would be lost. +// 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; @@ -617,7 +637,7 @@ TEST_CASE("An item the file has twice is a locked row of the import [data][items const std::filesystem::path file = folder.Path() / "ItemExchange.json"; WriteFile(file, WriteItemExchangeJson(sides.file)); - g_MuItemSyncUI.Compare(SyncDirection::Import, file); + g_MuItemSyncUI.Compare(file); REQUIRE(g_MuItemSyncUI.GetDiff()); const SyncItem* row = FindRow(*g_MuItemSyncUI.GetDiff(), KrisType); REQUIRE(row != nullptr); @@ -631,8 +651,6 @@ TEST_CASE("An item the file has twice is a locked row of the import [data][items REQUIRE(g_ItemEdits.Undo()); g_ItemEdits.GetHistory().Clear(); - g_MuItemSyncUI.Compare(SyncDirection::Export, file); - CHECK_FALSE(g_MuItemSyncUI.GetDiff()); } TEST_CASE("The known differences are written and read back [data][items][editor]") From 1f5f4a96bf5f0b5cd45e581cab5ce7d7f27d5d44 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 11:14:29 +0200 Subject: [PATCH 14/32] fix(editor): Item Sync's confirmations stay until answered, its dialogs keep their action - The import and export confirmations open again while they wait for an answer: a click after F12 hid the editor closed them, and nothing was applied or written without a word. - Each file dialog keeps its own action (its userdata): with an import and an export dialog open at once, the file picked to import became the export target. A dialog's answer waits while a confirmation is open, so the confirmation keeps its diff. - After an export the window compares again when the import's file is the same file (also written differently, or not readable before). - The "In the client" column takes the width of its widest text. - The help text says that only Import lists differences; the design doc no longer says that an export refuses a file with an item twice, and the docs say "added" where the window does. --- docs/item-data.md | 6 +- .../2026-09-25-data-driven-items-design.md | 8 +- src/Localization/Editor.de.resx | 2 +- src/Localization/Editor.en.resx | 2 +- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 80 ++++++++++++++----- src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 29 +++---- 6 files changed, 82 insertions(+), 45 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index b4bdb305de..a83011036a 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1172,7 +1172,7 @@ number, fields with their default value left out, LF line endings. - **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). -- **New items** get the client-only fields at their defaults; the +- **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 @@ -1188,8 +1188,8 @@ number, fields with their default value left out, LF line endings. 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 a new -or removed item or on a formula entry leaves it unchecked and grey in +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 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 9e19709c1f..62cea55e5d 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 @@ -396,8 +396,8 @@ Each field belongs to one of two groups: 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 neither side applies; an - export does not write into such a file, as it would lose both). + 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 @@ -436,7 +436,7 @@ tool of section 9): `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 new ones, the changed ones with their +- **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. @@ -455,7 +455,7 @@ tool of section 9): 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. -- **New items:** `twoHanded` follows the rule all shipped items follow +- **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 diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 299172e85f..e31cfaef9c 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2114,7 +2114,7 @@ Neu vergleichen - Importieren listet auf, wie sich eine Datei von den Items und Formeln des Clients unterscheidet, und übernimmt die angehakten Werte in den Client. Exportieren schreibt die Items und Formeln des Clients in eine Datei; die Import-Seite von OpenMU zeigt, was sie dort ändert. Nur Unterschiede werden aufgelistet: Was auf beiden Seiten gleich ist, fehlt in der Liste. + 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} diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index d1091f6e99..93f183d683 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2135,7 +2135,7 @@ Compare again - Import lists how a file differs from the client's items and formulas and takes the checked values into the client. Export writes the client's items and formulas into a file; OpenMU's import page shows what it changes there. Only what differs is listed: what is the same on both sides is left out. + 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} diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 79f2ed78bc..85c948c1c4 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -24,8 +24,10 @@ #include #include +#include #include #include +#include #include using namespace MuEditor::Items; @@ -34,7 +36,7 @@ using Data::Items::ItemDataIssue; namespace { // Pixel widths before the editor UI scale. -constexpr float ChangeColumnWidth = 80.0f; +constexpr float FieldsColumnWidth = 80.0f; constexpr float FieldColumnWidth = 220.0f; constexpr float KnownColumnWidth = 60.0f; constexpr float ItemListHeight = 260.0f; @@ -195,13 +197,39 @@ void RenderValue(const std::optional& value) ImGui::PopTextWrapPos(); } -void SDLCALL OnFileChosen(void*, const char* const* files, int) +// 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) { - g_MuItemSyncUI.ChooseFile(files[0]); + 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 in the client: its widest text. +float ChangeColumnWidth() +{ + float width = 0.0f; + for (const char* text : + {I18N::Editor::InTheClient, I18N::Editor::Added, I18N::Editor::Changed, I18N::Editor::Removed}) + { + width = std::max(width, ImGui::CalcTextSize(text).x); } + return width; +} + +// 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 @@ -257,14 +285,20 @@ void CMuItemSyncUI::Render(bool* open) ToolWindow::End(); } -void CMuItemSyncUI::ChooseFile(std::string path) +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; { @@ -289,17 +323,13 @@ void CMuItemSyncUI::TakeChosenFile() void CMuItemSyncUI::OpenFileDialog(FileAction action) { static const SDL_DialogFileFilter Filters[] = {{I18N::Editor::ItemExchangeFiles, "json"}}; - { - const std::scoped_lock lock(m_chosenMutex); - m_chosenAction = action; - } if (action == FileAction::Import) { - SDL_ShowOpenFileDialog(&OnFileChosen, nullptr, nullptr, Filters, 1, nullptr, false); + SDL_ShowOpenFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, nullptr, false); } else { - SDL_ShowSaveFileDialog(&OnFileChosen, nullptr, nullptr, Filters, 1, ExchangeFileName); + SDL_ShowSaveFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, ExchangeFileName); } } @@ -494,9 +524,8 @@ void CMuItemSyncUI::Export(const std::filesystem::path& path) m_exportResult = I18N::Format(I18N::Editor::TheFileAlreadyHasTheClientData, {m_exportPath}); return; } - // A file that is there is replaced only after the confirmation. + // A file that is there is replaced only after the confirmation (RenderExportPopup). m_pendingExport = preview; - m_openExportPopup = true; } bool CMuItemSyncUI::ConfirmExport() @@ -532,10 +561,11 @@ bool CMuItemSyncUI::WriteExport() m_exportFailed = false; m_exportResult = I18N::Format(I18N::Editor::Exported0ItemsTo1, {std::to_string(writtenItems), m_exportPath}); g_MuEditorConsoleUI.LogEditor(m_exportResult); - // An import comparison of that file shows what it held before. - if (m_diff && m_comparedPath == m_exportPath) + // An import comparison of that file (also one that could not read it) shows what it held + // before. + if (IsSameFile(m_comparedPath, m_exportPath)) { - Compare(ToPath(m_exportPath)); + Compare(ToPath(m_comparedPath)); } return true; } @@ -692,9 +722,9 @@ void CMuItemSyncUI::RenderItems() { ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); - ImGui::TableSetupColumn(I18N::Editor::InTheClient, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); + ImGui::TableSetupColumn(I18N::Editor::InTheClient, ImGuiTableColumnFlags_WidthFixed, ChangeColumnWidth()); ImGui::TableSetupColumn(I18N::Editor::Item); - ImGui::TableSetupColumn(I18N::Editor::Fields, ImGuiTableColumnFlags_WidthFixed, Scaled(ChangeColumnWidth)); + ImGui::TableSetupColumn(I18N::Editor::Fields, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldsColumnWidth)); ImGui::TableHeadersRow(); ImGuiListClipper clipper; clipper.Begin(static_cast(m_diff->items.size())); @@ -939,7 +969,7 @@ void CMuItemSyncUI::RenderApply() // It asks first (RenderApplyPopup). if (ImGui::Button(I18N::Editor::ApplyTheCheckedChanges)) { - m_openApplyPopup = true; + m_confirmApply = true; } ImGui::EndDisabled(); ImGui::PushTextWrapPos(0.0f); @@ -963,10 +993,14 @@ void CMuItemSyncUI::RenderApply() void CMuItemSyncUI::RenderApplyPopup() { - if (m_openApplyPopup) + // 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); - m_openApplyPopup = false; } const std::string title = std::string(I18N::Editor::ApplyTheImport) + ApplyPopupId; if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize)) @@ -984,6 +1018,7 @@ void CMuItemSyncUI::RenderApplyPopup() ImGui::BeginDisabled(GetApplyBlocker() != ApplyBlocker::None); if (ImGui::Button(I18N::Editor::Apply)) { + m_confirmApply = false; Apply(); ImGui::CloseCurrentPopup(); } @@ -991,6 +1026,7 @@ void CMuItemSyncUI::RenderApplyPopup() ImGui::SameLine(); if (ImGui::Button(I18N::Editor::Cancel)) { + m_confirmApply = false; ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); @@ -998,10 +1034,10 @@ void CMuItemSyncUI::RenderApplyPopup() void CMuItemSyncUI::RenderExportPopup() { - if (m_openExportPopup) + // Open while the export waits for its answer, also when ImGui closed it. + if (m_pendingExport && !ImGui::IsPopupOpen(ExportPopupId)) { ImGui::OpenPopup(ExportPopupId); - m_openExportPopup = false; } const std::string title = std::string(I18N::Editor::ReplaceTheExchangeFile) + ExportPopupId; if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize)) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h index 56cc9382ed..25606c83cd 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -76,9 +76,15 @@ class CMuItemSyncUI return m_exportResult; } + // The button whose file dialog answers. + enum class FileAction : uint8_t + { + Import, + Export, + }; // Takes the path a file dialog chose, on the dialog's thread; the window uses it in its next - // frame. - void ChooseFile(std::string path); + // frame without a confirmation open. + void ChooseFile(std::string path, FileAction action); // The diff shown, for its checkboxes. std::optional& GetDiff() { @@ -95,13 +101,6 @@ class CMuItemSyncUI } private: - // The button whose file dialog answers. - enum class FileAction : uint8_t - { - Import, - Export, - }; - CMuItemSyncUI() = default; void TakeChosenFile(); @@ -140,17 +139,19 @@ class CMuItemSyncUI std::vector m_messages; bool m_messagesAreErrors = false; std::string m_result; - bool m_openApplyPopup = false; + // 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, and what - // the last one did. + // 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; - bool m_openExportPopup = false; std::string m_exportResult; bool m_exportFailed = false; - // The file dialogs answer on their own thread: the path waits here for the next frame. + // 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; From 17af7ffffb03e886a2ef817f4c3e82e9b30e5933 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 11:50:09 +0200 Subject: [PATCH 15/32] docs(items): an item's description stays with the client in the exchange file OpenMU's items have no description, so the exchange file leaves it out like the other client-only fields: a file with one is read with a warning, and an import keeps the client's. --- docs/item-data.md | 7 ++++--- .../specs/2026-09-25-data-driven-items-design.md | 6 +++--- tests/data/test_item_exchange_file.cpp | 10 ++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index a83011036a..2eb299c142 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1143,9 +1143,10 @@ number, fields with their default value left out, LF line endings. 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 - 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. + 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**: 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 62cea55e5d..00096c69d4 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 @@ -372,9 +372,9 @@ Each field belongs to one of two groups: 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; - the other tags, `wingTier`, `weaponKind`, `twoHanded`, `potionPriority`, - `attackType`, `magicDefense`, the rental flags) and what is server-only - (`DropsFromMonsters`, `MaximumDropLevel`, `MaximumItemLevel`, + `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`). diff --git a/tests/data/test_item_exchange_file.cpp b/tests/data/test_item_exchange_file.cpp index 7dd19cb47b..606708a51c 100644 --- a/tests/data/test_item_exchange_file.cpp +++ b/tests/data/test_item_exchange_file.cpp @@ -180,6 +180,16 @@ TEST_CASE("The exchange file reader leaves out what stays with the client [data] 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; From 1545e9128943039931ad1397a4ba2876dc0660b5 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 13:31:07 +0200 Subject: [PATCH 16/32] feat(editor): Item Sync compares two exchange files, read-only Compare two files (folded under the path field) lists what changes from file A to file B, e.g. a client export and a server export, without the client's data: in every language, also one only one file has, with the formulas. Nothing can be applied; a row can be marked as a known difference. An import and a comparison of two files replace each other in the window. Only the client has it: OpenMU's import page compares a file with its live data. --- docs/item-data.md | 8 + .../2026-09-25-data-driven-items-design.md | 6 + src/Localization/Editor.de.resx | 48 +++ src/Localization/Editor.en.resx | 48 +++ src/MuEditor/README.md | 2 +- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 11 +- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 8 +- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 322 +++++++++++++----- src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 24 +- tests/data/test_item_sync_diff.cpp | 48 +++ 10 files changed, 437 insertions(+), 88 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index 2eb299c142..d8af7caf04 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1086,6 +1086,14 @@ Update from server, which downloads it, are later steps of phase 7; see the 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"), in every language, also one only one file has. 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 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 00096c69d4..b8dab4660a 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 @@ -432,6 +432,12 @@ tool of section 9): 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 diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index e31cfaef9c..e0f3e3f1e8 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2281,4 +2281,52 @@ {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. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 93f183d683..7bbcc5b1f8 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2302,4 +2302,52 @@ 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. + diff --git a/src/MuEditor/README.md b/src/MuEditor/README.md index e0b64ac8d3..4442f6946b 100644 --- a/src/MuEditor/README.md +++ b/src/MuEditor/README.md @@ -49,7 +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) +- **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) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index f6e55107d7..f9effc4453 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -122,11 +122,12 @@ bool IsNamePath(const std::string& path, std::string& locale) } // 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. +// and an import takes names only in the client's UI languages. A comparison of two files +// (everyLanguage) takes every one. bool TakesPart(const std::string& path, bool incomingHasIt, const SyncOptions& options) { std::string locale; - if (!IsNamePath(path, locale)) + if (!IsNamePath(path, locale) || options.everyLanguage) { return true; } @@ -598,8 +599,10 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang const ItemExchangeFile& incoming = file; const std::map currentItems = IndexItems(current); const std::map incomingItems = IndexItems(incoming); - // An item the file has more than once: an error row that is not applied. - const std::set repeated(file.repeatedItems.begin(), file.repeatedItems.end()); + // An item a side has more than once (a file read with repeatedItems): an error row that is + // not applied. + std::set repeated(file.repeatedItems.begin(), file.repeatedItems.end()); + repeated.insert(client.repeatedItems.begin(), client.repeatedItems.end()); std::set keys = repeated; for (const auto& [key, item] : currentItems) { diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 3fd42715df..2400327f49 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -117,13 +117,15 @@ struct SyncOptions // 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. + bool everyLanguage = false; }; // The differences between the client's data (`client`, BuildItemExchange) and `file`. Checked // by default: what the file changes; unchecked: removals, known differences, inexact and locked -// fields. A language one side lacks stays as it is on the other. An item the file has more than -// once (its repeatedItems) is a locked row without fields. The formulas are compared while both -// sides have them. +// fields. A language one side lacks stays as it is on the other (unless everyLanguage). 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); diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 85c948c1c4..bfb6efc6d7 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -29,6 +29,7 @@ #include #include #include +#include using namespace MuEditor::Items; using Data::Items::ItemDataIssue; @@ -213,18 +214,62 @@ void SDLCALL OnFileChosen(void* userdata, const char* const* files, int) } } -// The width of the column that says what a row does in the client: its widest text. +// 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::Added, I18N::Editor::Changed, I18N::Editor::Removed}) + 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) { @@ -268,12 +313,16 @@ void CMuItemSyncUI::Render(bool* open) { ImGui::Separator(); RenderSummary(); - RenderFieldToggle(); + // 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_diff->IsEmpty()) + if (!m_comparingFiles && !m_diff->IsEmpty()) { RenderApply(); } @@ -310,26 +359,33 @@ void CMuItemSyncUI::TakeChosenFile() { return; } - if (action == FileAction::Export) - { - Export(ToPath(*path)); - } - else + 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) { static const SDL_DialogFileFilter Filters[] = {{I18N::Editor::ItemExchangeFiles, "json"}}; - if (action == FileAction::Import) + if (action == FileAction::Export) { - SDL_ShowOpenFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, nullptr, false); + SDL_ShowSaveFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, ExchangeFileName); } else { - SDL_ShowSaveFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, ExchangeFileName); + SDL_ShowOpenFileDialog(&OnFileChosen, ToUserData(action), nullptr, Filters, 1, nullptr, false); } } @@ -358,6 +414,43 @@ void CMuItemSyncUI::RenderButtons() 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() @@ -373,6 +466,7 @@ void CMuItemSyncUI::RenderExportResult() void CMuItemSyncUI::Compare(const std::filesystem::path& path) { + m_comparingFiles = false; m_path = ToText(path); m_comparedPath = m_path; m_result.clear(); @@ -384,21 +478,12 @@ void CMuItemSyncUI::Compare(const std::filesystem::path& path) m_file = {}; std::vector issues; - if (const std::optional text = Data::Items::Json::ReadTextFile(path)) - { - ReadItemExchangeJson(*text, m_path, m_file, issues); - } - else - { - issues.push_back({Data::Items::ItemDataIssueSeverity::Error, m_path, ItemDataIssue::NoItem, - ItemDataIssue::NoItem, "", "could not be read"}); - } + const bool usable = ReadExchangeFile(path, m_path, m_file, issues); for (const ItemDataIssue& issue : issues) { m_messages.push_back(issue.ToString()); } - // An item the file has twice is a row the diff locks. - if (!Data::Items::CanImportItemExchange(m_file, issues)) + if (!usable) { m_messagesAreErrors = true; return; @@ -414,6 +499,43 @@ void CMuItemSyncUI::Compare(const std::filesystem::path& path) m_diffVersion = g_ItemDatabase.GetVersion(); } +void CMuItemSyncUI::CompareFiles(const std::filesystem::path& a, const std::filesystem::path& b) +{ + m_comparingFiles = true; + 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.everyLanguage = true; + m_diff = BuildItemSyncDiff(m_fileA, m_file, options); +} + CMuItemSyncUI::ApplyBlocker CMuItemSyncUI::GetApplyBlocker() const { if (m_path != m_comparedPath) @@ -462,7 +584,7 @@ CMuItemSyncUI::CheckedChanges CMuItemSyncUI::CountCheckedChanges() const bool CMuItemSyncUI::Apply() { - if (!m_diff || GetApplyBlocker() != ApplyBlocker::None) + if (m_comparingFiles || !m_diff || GetApplyBlocker() != ApplyBlocker::None) { return false; } @@ -561,9 +683,12 @@ bool CMuItemSyncUI::WriteExport() m_exportFailed = false; m_exportResult = I18N::Format(I18N::Editor::Exported0ItemsTo1, {std::to_string(writtenItems), m_exportPath}); g_MuEditorConsoleUI.LogEditor(m_exportResult); - // An import comparison of that file (also one that could not read it) shows what it held - // before. - if (IsSameFile(m_comparedPath, m_exportPath)) + // 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)); } @@ -584,13 +709,32 @@ void CMuItemSyncUI::SetAllChecked(bool checked) void CMuItemSyncUI::RenderSummary() { - ImGui::TextUnformatted(I18N::Format(I18N::Editor::ImportFrom0, {m_comparedPath}).c_str()); - if (m_file.source == Data::Items::ItemExchangeSourceOpenMU) - { - ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FromOpenMUServer0, - {m_file.server.empty() ? std::string(NoValue) : m_file.server}) - .c_str()); + 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()) @@ -611,15 +755,6 @@ void CMuItemSyncUI::RenderSummary() { ImGui::TextDisabled("%s", I18N::Editor::StillListedBelowTheRowsThatWereNotChecked); } - int added = 0; - int changed = 0; - int removed = 0; - for (const SyncItem& row : m_diff->items) - { - added += row.change == SyncChange::New ? 1 : 0; - changed += row.change == SyncChange::Changed ? 1 : 0; - removed += row.change == SyncChange::Removed ? 1 : 0; - } 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())}) @@ -703,8 +838,9 @@ void CMuItemSyncUI::RenderItems() { if (m_diff->comparedItems > 0) { - ImGui::TextDisabled( - "%s", I18N::Format(I18N::Editor::ItemsAll0AreTheSame, {std::to_string(m_diff->sameItems)}).c_str()); + 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; } @@ -716,13 +852,19 @@ void CMuItemSyncUI::RenderItems() { 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", 4, Flags, ImVec2(0.0f, Scaled(ItemListHeight)))) + if (ImGui::BeginTable("##SyncItems", checkable ? 4 : 3, Flags, ImVec2(0.0f, Scaled(ItemListHeight)))) { ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); - ImGui::TableSetupColumn(I18N::Editor::InTheClient, ImGuiTableColumnFlags_WidthFixed, ChangeColumnWidth()); + 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(); @@ -735,14 +877,17 @@ void CMuItemSyncUI::RenderItems() SyncItem& row = m_diff->items[static_cast(index)]; ImGui::PushID(index); ImGui::TableNextRow(); - ImGui::TableNextColumn(); - bool checked = IsRowChecked(row); - ImGui::BeginDisabled(!row.locked.empty()); - if (ImGui::Checkbox("##Checked", &checked)) + if (checkable) { - SetRowChecked(row, checked); + ImGui::TableNextColumn(); + bool checked = IsRowChecked(row); + ImGui::BeginDisabled(!row.locked.empty()); + if (ImGui::Checkbox("##Checked", &checked)) + { + SetRowChecked(row, checked); + } + ImGui::EndDisabled(); } - ImGui::EndDisabled(); ImGui::TableNextColumn(); ImGui::TextUnformatted(ChangeName(row.change)); ImGui::TableNextColumn(); @@ -759,7 +904,8 @@ void CMuItemSyncUI::RenderItems() { g_ItemSelection.Select(itemType); } - m_shownUsage = row.change == SyncChange::Removed ? FindItemUsage(itemType) : ItemUsage{}; + const bool removedFromClient = checkable && row.change == SyncChange::Removed; + m_shownUsage = removedFromClient ? FindItemUsage(itemType) : ItemUsage{}; } if (row.known) { @@ -785,8 +931,8 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) { ImGui::TextColored(WarningColor, "%s", row.locked.c_str()); } - // What Remove item lists before it removes an item. - if (row.change == SyncChange::Removed) + // 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()) @@ -797,7 +943,7 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) ImGui::TextUnformatted(I18N::Editor::WhatUsesItStaysAtItsNumber); UsageView::RenderUsesAtNumber(m_shownUsage); } - if (row.change == SyncChange::New) + 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; }); @@ -828,18 +974,24 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) constexpr ImGuiTableFlags Flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; const bool changed = row.change == SyncChange::Changed; - if (!ImGui::BeginTable("##SyncFields", changed ? 6 : 3, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) + // A comparison of two files has no boxes, and its sides are file A and file B. + const bool checkable = !m_comparingFiles; + 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); - ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + if (checkable) + { + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + } ImGui::TableSetupColumn(I18N::Editor::Field, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); if (changed) { - ImGui::TableSetupColumn(I18N::Editor::Now); + ImGui::TableSetupColumn(checkable ? I18N::Editor::Now : I18N::Editor::FileA); } - ImGui::TableSetupColumn(I18N::Editor::FromTheFile); + ImGui::TableSetupColumn(checkable ? I18N::Editor::FromTheFile : I18N::Editor::FileB); if (changed) { ImGui::TableSetupColumn(I18N::Editor::Note); @@ -851,12 +1003,15 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) SyncField& field = row.fields[index]; ImGui::PushID(static_cast(index)); ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (changed) + if (checkable) { - ImGui::BeginDisabled(!field.locked.empty()); - ImGui::Checkbox("##Checked", &field.checked); - ImGui::EndDisabled(); + ImGui::TableNextColumn(); + if (changed) + { + ImGui::BeginDisabled(!field.locked.empty()); + ImGui::Checkbox("##Checked", &field.checked); + ImGui::EndDisabled(); + } } ImGui::TableNextColumn(); ImGui::TextUnformatted(field.field.c_str()); @@ -895,11 +1050,14 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) void CMuItemSyncUI::RenderFormulas() { - // A file without formulas leaves the client's as they are. - if (!m_file.formulas) + 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", I18N::Editor::FormulasNotComparedTheFileHasNone); + ImGui::TextDisabled("%s", m_comparingFiles ? I18N::Editor::FormulasNotComparedAFileHasNone + : I18N::Editor::FormulasNotComparedTheFileHasNone); ImGui::PopTextWrapPos(); return; } @@ -907,9 +1065,9 @@ void CMuItemSyncUI::RenderFormulas() { if (m_diff->comparedFormulaEntries > 0) { - ImGui::TextDisabled("%s", I18N::Format(I18N::Editor::FormulaEntriesAll0AreTheSame, - {std::to_string(m_diff->sameFormulaEntries)}) - .c_str()); + 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; } @@ -923,15 +1081,18 @@ void CMuItemSyncUI::RenderFormulas() } constexpr ImGuiTableFlags Flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp; - if (!ImGui::BeginTable("##SyncFormulas", 5, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) + if (!ImGui::BeginTable("##SyncFormulas", checkable ? 5 : 4, Flags, ImVec2(0.0f, Scaled(FieldListHeight)))) { return; } ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + if (checkable) + { + ImGui::TableSetupColumn(CheckColumn, ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + } ImGui::TableSetupColumn(I18N::Editor::Entry, ImGuiTableColumnFlags_WidthFixed, Scaled(FieldColumnWidth)); - ImGui::TableSetupColumn(I18N::Editor::Now); - ImGui::TableSetupColumn(I18N::Editor::FromTheFile); + 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) @@ -939,8 +1100,11 @@ void CMuItemSyncUI::RenderFormulas() SyncFormula& row = m_diff->formulas[index]; ImGui::PushID(static_cast(index)); ImGui::TableNextRow(); - ImGui::TableNextColumn(); - ImGui::Checkbox("##Checked", &row.checked); + if (checkable) + { + ImGui::TableNextColumn(); + ImGui::Checkbox("##Checked", &row.checked); + } ImGui::TableNextColumn(); ImGui::TextUnformatted(row.entry.c_str()); ImGui::TableNextColumn(); diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h index 25606c83cd..958fc21467 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -22,7 +22,9 @@ // 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. +// 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: @@ -32,6 +34,13 @@ class CMuItemSyncUI // 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 @@ -81,6 +90,9 @@ class CMuItemSyncUI { 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. @@ -107,6 +119,7 @@ class CMuItemSyncUI void SetAllChecked(bool checked); void OpenFileDialog(FileAction action); void RenderButtons(); + void RenderCompareFiles(); void RenderExportResult(); void RenderSummary(); void RenderFieldToggle(); @@ -125,7 +138,16 @@ class CMuItemSyncUI // 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; diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index c1f936f99d..63d42e93ba 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -507,6 +507,54 @@ TEST_CASE("Item Sync exports the client's items and formulas [data][items][edito } } +// 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; + 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() == 3); + 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]") From 5c4f7ed10521b44b587f23943540e878b09ca1f4 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 13:40:13 +0200 Subject: [PATCH 17/32] fix(editor): Item Sync's comparison of two files shows repeated and removed items right An item file A has twice was listed as added, without a name, as the file leaves it out of its items; its row now says changed or removed by what file B has, takes the name the files give, and names the file that has it twice instead of the import's text about the client. An item only file A has lists file A's values. --- docs/item-data.md | 8 ++-- src/Localization/Editor.de.resx | 6 +++ src/Localization/Editor.en.resx | 6 +++ src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 48 +++++++++++++++++----- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 9 ++-- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 12 ++++-- tests/data/test_item_sync_diff.cpp | 22 +++++++++- 7 files changed, 89 insertions(+), 22 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index d8af7caf04..b857d5ca18 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1091,9 +1091,11 @@ Update from server, which downloads it, are later steps of phase 7; see the 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"), in every language, also one only one file has. 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. + "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 diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index e0f3e3f1e8..a78a6fdb97 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2329,4 +2329,10 @@ 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 7bbcc5b1f8..e35c644c24 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2350,4 +2350,10 @@ 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/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index f9effc4453..e034a36467 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -123,11 +123,11 @@ bool IsNamePath(const std::string& path, std::string& locale) // 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 -// (everyLanguage) takes every one. +// (comparingFiles) takes every one. bool TakesPart(const std::string& path, bool incomingHasIt, const SyncOptions& options) { std::string locale; - if (!IsNamePath(path, locale) || options.everyLanguage) + if (!IsNamePath(path, locale) || options.comparingFiles) { return true; } @@ -207,6 +207,14 @@ SyncItem CompareItem(const ItemKey& key, const ItemDefinition* current, const It 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; @@ -599,10 +607,12 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang const ItemExchangeFile& incoming = file; const std::map currentItems = IndexItems(current); const std::map incomingItems = IndexItems(incoming); - // An item a side has more than once (a file read with repeatedItems): an error row that is - // not applied. - std::set repeated(file.repeatedItems.begin(), file.repeatedItems.end()); - repeated.insert(client.repeatedItems.begin(), client.repeatedItems.end()); + // 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) { @@ -616,18 +626,36 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang 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 = was != currentItems.end() ? SyncChange::Changed : SyncChange::New; - row.name = was != currentItems.end() ? was->second->names.GetNeutral() : std::string(); - row.locked = I18N::Editor::ItIsInTheFileMoreThanOnce; + 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; } - const auto becomes = incomingItems.find(key); 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 --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 2400327f49..1b199db6d9 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -117,14 +117,15 @@ struct SyncOptions // 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. - bool everyLanguage = false; + // 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, known differences, inexact and locked -// fields. A language one side lacks stays as it is on the other (unless everyLanguage). An item a -// side has more than once (its repeatedItems) is a locked row without fields. The formulas are +// 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); diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index bfb6efc6d7..e7765f8a7b 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -532,7 +532,7 @@ void CMuItemSyncUI::CompareFiles(const std::filesystem::path& a, const std::file } SyncOptions options; options.known = &m_known; - options.everyLanguage = true; + options.comparingFiles = true; m_diff = BuildItemSyncDiff(m_fileA, m_file, options); } @@ -974,8 +974,10 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) 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. + // 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)))) { @@ -991,7 +993,9 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) { ImGui::TableSetupColumn(checkable ? I18N::Editor::Now : I18N::Editor::FileA); } - ImGui::TableSetupColumn(checkable ? I18N::Editor::FromTheFile : I18N::Editor::FileB); + ImGui::TableSetupColumn(removed ? I18N::Editor::FileA + : checkable ? I18N::Editor::FromTheFile + : I18N::Editor::FileB); if (changed) { ImGui::TableSetupColumn(I18N::Editor::Note); @@ -1021,7 +1025,7 @@ void CMuItemSyncUI::RenderItemFields(SyncItem& row) RenderValue(field.current); } ImGui::TableNextColumn(); - RenderValue(field.incoming); + RenderValue(removed ? field.current : field.incoming); if (changed) { ImGui::TableNextColumn(); diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index 63d42e93ba..b12fc543cc 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -526,6 +526,13 @@ TEST_CASE("Item Sync compares two files read-only [data][items][editor]") 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)); @@ -533,7 +540,20 @@ TEST_CASE("Item Sync compares two files read-only [data][items][editor]") CHECK(g_MuItemSyncUI.IsComparingFiles()); REQUIRE(g_MuItemSyncUI.GetDiff()); const ItemSyncDiff& diff = *g_MuItemSyncUI.GetDiff(); - CHECK(diff.items.size() == 3); + 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); From 7bed1900e2bfef4caa5a83b3e6de5bec27502cfd Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:16:20 +0200 Subject: [PATCH 18/32] fix(editor): Item Sync leaves a formula entry only the client has unchecked Every formula row started checked, also a profile or table the file lacks, so the default Apply removed it; with the orb profile gone the orbs named a profile that was not there, and the checks refused the whole import. Such a row starts unchecked now, as a removed item does. --- docs/item-data.md | 5 +++-- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 4 +++- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 8 ++++---- tests/data/test_item_sync_diff.cpp | 15 +++++++++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index b857d5ca18..60a4700980 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -1171,8 +1171,9 @@ number, fields with their default value left out, LF line endings. `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, - known differences (grey) and fields with a note: the size and slot of an +- 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. diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index e034a36467..05aafef029 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -450,7 +450,9 @@ void CompareFormulas(const Data::Items::ItemStatFile& current, const Data::Items } } row.known = options.known != nullptr && options.known->Contains(row.entry); - row.checked = !row.known; + // 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)); } } diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 1b199db6d9..58bff203d8 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -123,10 +123,10 @@ struct SyncOptions }; // The differences between the client's data (`client`, BuildItemExchange) and `file`. Checked -// by default: what the file changes; unchecked: removals, 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. +// 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); diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index b12fc543cc..f182c77b17 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -327,6 +327,21 @@ TEST_CASE("The formulas differ by drop level bonus, table, term and whole profil 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; From 7a431f5c1271d03d093dd8d8e5fc46add7ff25b5 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:17:54 +0200 Subject: [PATCH 19/32] fix(editor): Item Sync's import keeps the stat profile the file picks for an item Storing an item lets its profile follow the rules when the item is new or its profile field does not change, so an added wing the file gives secondTierWing got the default profile its rules give without a wing tier. The import decides each item's profile itself ("" follows the rules, another one is a pick), so it stores them as given (StatProfileStore::AsGiven); the item tools keep following the data. --- src/MuEditor/UI/ItemTools/ItemEdits.cpp | 5 +++-- src/MuEditor/UI/ItemTools/ItemEdits.h | 6 +++++- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 3 ++- .../Data/DataHandler/ItemData/ItemDataHandler.cpp | 11 +++++++---- .../Data/DataHandler/ItemData/ItemDataHandler.h | 15 ++++++++++----- .../GameData/ItemData/ItemAttributeConversion.h | 10 ++++++++++ tests/data/test_item_sync_diff.cpp | 12 +++++++++++- 7 files changed, 48 insertions(+), 14 deletions(-) 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 index 2d9ef48330..07e22ec902 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -260,7 +260,8 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, } if (!items.empty()) { - g_ItemEdits.SetItems(label, items); + // ToClientItem decided each item's profile: "" follows the rules, another one is a pick. + g_ItemEdits.SetItems(label, items, Data::Items::StatProfileStore::AsGiven); } for (const int itemType : removed) { diff --git a/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp b/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp index 52b7e8b3ea..be73349e89 100644 --- a/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp @@ -272,7 +272,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) @@ -284,7 +285,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); } @@ -306,7 +308,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()); @@ -314,7 +316,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); diff --git a/src/source/Data/DataHandler/ItemData/ItemDataHandler.h b/src/source/Data/DataHandler/ItemData/ItemDataHandler.h index 3d2e64f830..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[]. @@ -240,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/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/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index f182c77b17..bbae00dd22 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -383,6 +383,12 @@ TEST_CASE("Item Sync imports the checked rows of a file as one undo step [data][ 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; @@ -392,7 +398,7 @@ TEST_CASE("Item Sync imports the checked rows of a file as one undo step [data][ g_MuItemSyncUI.Compare(file); REQUIRE(g_MuItemSyncUI.GetDiff()); - CHECK(g_MuItemSyncUI.GetDiff()->items.size() == 3); + CHECK(g_MuItemSyncUI.GetDiff()->items.size() == 4); CHECK(g_MuItemSyncUI.GetDiff()->formulas.size() == 1); REQUIRE(g_MuItemSyncUI.Apply()); @@ -404,6 +410,9 @@ TEST_CASE("Item Sync imports the checked rows of a file as one undo step [data][ 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); @@ -417,6 +426,7 @@ TEST_CASE("Item Sync imports the checked rows of a file as one undo step [data][ 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(); } From 991507deab85a4bb03caac635dedf509b84b48e6 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:18:32 +0200 Subject: [PATCH 20/32] fix(editor): Item Sync unchecks the formula rows when the formulas refuse an import An issue of the formulas has no item, so a refusal they caused unchecked no row and the next Apply failed the same way. Such a refusal unchecks all formula rows now, and the message says so. --- src/Localization/Editor.de.resx | 2 +- src/Localization/Editor.en.resx | 2 +- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 12 +++++++- tests/data/test_item_sync_diff.cpp | 34 ++++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index a78a6fdb97..d04089b884 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -2183,7 +2183,7 @@ Items importieren - Die Prüfungen lehnen die Änderung ab; die Zeilen der Items mit Fehlern haben keinen Haken mehr: + 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} diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index e35c644c24..6a45faa4ec 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -2204,7 +2204,7 @@ Import items - The checks refuse the change; the rows of the items with errors are unchecked: + 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} diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index e7765f8a7b..80e8a7d7c7 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -597,10 +597,13 @@ bool CMuItemSyncUI::Apply() 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. + // 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) @@ -609,6 +612,13 @@ bool CMuItemSyncUI::Apply() } } } + if (formulasRefused) + { + for (SyncFormula& row : m_diff->formulas) + { + row.checked = false; + } + } m_messagesAreErrors = true; return false; } diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index bbae00dd22..e5f41cb662 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -369,6 +369,40 @@ TEST_CASE("Formulas the item stat file's checks refuse are not taken [data][item 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]") From c1252f6b97736c943af45259add63b7dcad98f00 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:18:55 +0200 Subject: [PATCH 21/32] fix(editor): applying an Item Sync diff skips a changed item the client no longer has targetItems.at threw for a Changed row whose item the client lost since the diff; the window's data-changed blocker prevented it, but ApplyItemSyncDiff and ApplyItemSyncImport are public. The row is left out, as the New and Removed branches leave theirs. --- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 8 +++++++- tests/data/test_item_sync_diff.cpp | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index 05aafef029..69473868d0 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -723,7 +723,13 @@ ItemExchangeFile ApplyItemSyncDiff(const ItemExchangeFile& client, const ItemExc targetItems[key] = UnflattenItem(row.group, row.number, fields, issues); continue; } - Fields fields = FlattenItem(targetItems.at(key)); + // 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) { diff --git a/tests/data/test_item_sync_diff.cpp b/tests/data/test_item_sync_diff.cpp index e5f41cb662..2a1d859aec 100644 --- a/tests/data/test_item_sync_diff.cpp +++ b/tests/data/test_item_sync_diff.cpp @@ -748,6 +748,24 @@ TEST_CASE("Applying a diff takes a new item that lost a field since the comparis 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]") From 84ae1067ac2e041a1928a97e03c205f2933dc545 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:19:15 +0200 Subject: [PATCH 22/32] fix(items): an empty server in an exchange file is as if there were none An empty text was an error also for the optional server, so a file with "server": "" could be neither imported nor compared, though the writer leaves an empty server out. --- .../Data/GameData/ItemData/ItemExchangeFile.cpp | 11 ++++++++++- tests/data/test_item_exchange_file.cpp | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp index ce074ddf38..24d65ea688 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp @@ -78,11 +78,20 @@ bool ExchangeReader::ReadText(const OrderedJson& root, const char* key, bool req } return false; } - if (!field->is_string() || field->get_ref().empty()) + 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; } diff --git a/tests/data/test_item_exchange_file.cpp b/tests/data/test_item_exchange_file.cpp index 606708a51c..a5f142fcbd 100644 --- a/tests/data/test_item_exchange_file.cpp +++ b/tests/data/test_item_exchange_file.cpp @@ -139,6 +139,13 @@ TEST_CASE("The exchange file reader refuses what cannot be exchanged [data][item 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); From 94cc411dffc1a3ecfca625cebd8b430ec178f1e8 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:19:27 +0200 Subject: [PATCH 23/32] fix(editor): Item Sync's file dialogs name their filter in the editor's language The static filter kept the label of the first dialog, so after the editor's language changed the dialog still showed the old one; the label is set again before each dialog. --- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 80e8a7d7c7..34ad52e3e2 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -378,7 +378,10 @@ void CMuItemSyncUI::TakeChosenFile() void CMuItemSyncUI::OpenFileDialog(FileAction action) { - static const SDL_DialogFileFilter Filters[] = {{I18N::Editor::ItemExchangeFiles, "json"}}; + // 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); From 818ca0441118749dec5546f6457adeae5893c3ed Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:19:52 +0200 Subject: [PATCH 24/32] refactor(items): the original client's requirement growth in the bmd code has names The add, the percents and the item groups of GrowWeight and HasRequirementWeightsInBmd were bare numbers. They stay constants of the code rather than the item stat file's formulas: they describe what the original client's bmd files hold, which does not change with the data. --- .../DataHandler/ItemData/ItemBmdImport.cpp | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp index 6592adc8ac..208ea89583 100644 --- a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp @@ -334,6 +334,15 @@ constexpr GrownRequirement GrownRequirements[] = { {ItemRequirement::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) { @@ -342,16 +351,18 @@ int GrowWeight(const ItemDefinition& definition, ItemRequirement requirement, in return 0; } const bool energy = requirement == ItemRequirement::Energy; - const bool fromLevelRequirement = energy && definition.group == 15 && definition.requirements.level > 0; + 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 ? 4 : 3; - return static_cast(std::min(20 + weight * basis * percent / 100, MaxRequirement)); + const long long percent = energy ? OriginalEnergyRequirementPercent : OriginalRequirementPercent; + return static_cast( + std::min(OriginalRequirementAdd + weight * basis * percent / PercentDivisor, MaxRequirement)); } } // namespace bool HasRequirementWeightsInBmd(const ItemDefinition& definition) { - return definition.slot == ItemSlot::None && (definition.group == 12 || definition.group == 15); + return definition.slot == ItemSlot::None && + (definition.group == ITEM_GROUP_WING || definition.group == ITEM_GROUP_ETC); } void ToRequirementValues(ItemDefinition& definition) From 8780994382339fd0fc92763954216fcc8c375e50 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:23:12 +0200 Subject: [PATCH 25/32] fix(items): the bmd weight conversion follows the item's stat profile HasRequirementWeightsInBmd decided by group 12 or 15 and no slot whether an item keeps its requirements as values; in the game its profile decides. An orb given the default profile, or the orb profile changed back to a weight formula, held weights again, and Export as bmd and S6E3 turned them into other weights. A requirement converts now when the item's profile (the one it names, or the one its rules give) keeps its base value while the default grows it; with the shipped formulas these are the same 44 orbs, crystals and scrolls. Import from bmd converts in the handler, once each item's profile is known. --- docs/item-data.md | 2 +- .../2026-09-25-data-driven-items-design.md | 4 +- .../DataHandler/ItemData/ItemBmdImport.cpp | 61 ++++++++++++++----- .../Data/DataHandler/ItemData/ItemBmdImport.h | 25 +++++--- .../DataHandler/ItemData/ItemDataHandler.cpp | 11 +++- tests/data/test_item_database_files.cpp | 37 +++++++---- 6 files changed, 100 insertions(+), 40 deletions(-) diff --git a/docs/item-data.md b/docs/item-data.md index 60a4700980..1a40edd59d 100644 --- a/docs/item-data.md +++ b/docs/item-data.md @@ -130,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`. 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) | +| `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` | 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 b8dab4660a..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 @@ -415,7 +415,9 @@ 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). +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): diff --git a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp index 208ea89583..ef2d12733b 100644 --- a/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemBmdImport.cpp @@ -4,6 +4,7 @@ #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" @@ -298,10 +299,6 @@ void ImportItem(const std::vector& files, int itemType, ItemBmdImp CopyItemAttributeStats(MergeStats(records, itemType, result.repairs), definition); definition.group = GetItemGroup(itemType); definition.number = GetItemNumber(itemType); - if (HasRequirementWeightsInBmd(definition)) - { - ToRequirementValues(definition); - } for (const LanguageRecord& record : records) { // Also a name equal to the English one: the item can have the same @@ -324,14 +321,18 @@ constexpr int MaxRequirement = 65535; struct GrownRequirement { ItemRequirement requirement; + ItemRequirementStat stat; WORD ItemRequirements::* value; WORD ITEM_ATTRIBUTE::* weight; }; constexpr GrownRequirement GrownRequirements[] = { - {ItemRequirement::Strength, &ItemRequirements::strength, &ITEM_ATTRIBUTE::RequireStrength}, - {ItemRequirement::Dexterity, &ItemRequirements::dexterity, &ITEM_ATTRIBUTE::RequireDexterity}, - {ItemRequirement::Energy, &ItemRequirements::energy, &ITEM_ATTRIBUTE::RequireEnergy}, - {ItemRequirement::Vitality, &ItemRequirements::vitality, &ITEM_ATTRIBUTE::RequireVitality}, + {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 @@ -359,26 +360,56 @@ int GrowWeight(const ItemDefinition& definition, ItemRequirement requirement, in } } // namespace -bool HasRequirementWeightsInBmd(const ItemDefinition& definition) +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) { - return definition.slot == ItemSlot::None && - (definition.group == ITEM_GROUP_WING || definition.group == ITEM_GROUP_ETC); + const ItemStatProfile& profile = FindItemProfile(profiles, definition); + return std::ranges::any_of(GrownRequirements, [&](const GrownRequirement& grown) + { return HoldsWeightInBmd(profiles, profile, grown.stat); }); } -void ToRequirementValues(ItemDefinition& definition) +void ToRequirementValues(const ItemStatProfiles& profiles, ItemDefinition& definition) { + const ItemStatProfile& profile = FindItemProfile(profiles, definition); for (const GrownRequirement& grown : GrownRequirements) { - WORD& value = definition.requirements.*grown.value; - value = static_cast(GrowWeight(definition, grown.requirement, value)); + if (HoldsWeightInBmd(profiles, profile, grown.stat)) + { + WORD& value = definition.requirements.*grown.value; + value = static_cast(GrowWeight(definition, grown.requirement, value)); + } } } -std::vector ToBmdRequirementWeights(const ItemDefinition& definition, ITEM_ATTRIBUTE& attribute) +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; diff --git a/src/source/Data/DataHandler/ItemData/ItemBmdImport.h b/src/source/Data/DataHandler/ItemData/ItemBmdImport.h index 236ac90a70..2e85dc93bb 100644 --- a/src/source/Data/DataHandler/ItemData/ItemBmdImport.h +++ b/src/source/Data/DataHandler/ItemData/ItemBmdImport.h @@ -29,17 +29,22 @@ using ItemBmdLanguage = LegacyLanguage; // provides the stats. std::span GetItemBmdLanguages(); -// The orbs, crystals and scrolls (items without a slot in groups 12 and 15) keep their stat -// requirements as the values the game asks for, as OpenMU does. 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. -bool HasRequirementWeightsInBmd(const ItemDefinition& definition); -// Import from bmd: the weights of such an item become the values. -void ToRequirementValues(ItemDefinition& definition); -// Export as bmd: the smallest weight that gives each value of such an item, written into +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 ItemDefinition& definition, ITEM_ATTRIBUTE& attribute); +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 be73349e89..7337b460fd 100644 --- a/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp +++ b/src/source/Data/DataHandler/ItemData/ItemDataHandler.cpp @@ -71,11 +71,12 @@ std::unique_ptr BuildItemAttributes(std::string_view locale) // (ToBmdRequirementWeights), with a line of `notes` for each one no weight gives exactly. void WriteBmdRequirementWeights(const ItemDefinition& definition, ITEM_ATTRIBUTE& attribute, std::string& notes) { - if (!HasRequirementWeightsInBmd(definition)) + const ItemStatProfiles& profiles = g_ItemDatabase.GetStatProfiles(); + if (!HasRequirementWeightsInBmd(profiles, definition)) { return; } - for (const ItemRequirement requirement : ToBmdRequirementWeights(definition, attribute)) + for (const ItemRequirement requirement : ToBmdRequirementWeights(profiles, definition, attribute)) { notes += g_ItemDatabase.GetLogName(MakeItemType(definition.group, definition.number)) + ": no weight gives its " + FindEnumName(requirement) + @@ -697,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); diff --git a/tests/data/test_item_database_files.cpp b/tests/data/test_item_database_files.cpp index 1bbced04bb..1d2d9b32d3 100644 --- a/tests/data/test_item_database_files.cpp +++ b/tests/data/test_item_database_files.cpp @@ -1075,49 +1075,64 @@ 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; the original client's bmd -// files hold weights for them, which its code grew. +// 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(orb)); - ToRequirementValues(orb); + REQUIRE(HasRequirementWeightsInBmd(profiles, orb)); + ToRequirementValues(profiles, orb); CHECK(orb.requirements.energy == 52); ITEM_ATTRIBUTE attribute{}; - CHECK(ToBmdRequirementWeights(orb, attribute).empty()); + 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(scroll); + ToRequirementValues(profiles, scroll); CHECK(scroll.requirements.level == 220); CHECK(scroll.requirements.energy == 1058); - CHECK(ToBmdRequirementWeights(scroll, attribute).empty()); + 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(steps, attribute); + const std::vector inexact = ToBmdRequirementWeights(profiles, steps, attribute); CHECK(inexact == std::vector{ItemRequirement::Energy}); CHECK(attribute.RequireEnergy == 2); - // An item with a slot keeps its weights in both. + // A weapon keeps its weights in both. ItemDefinition kris; kris.group = 0; kris.slot = ItemSlot::MainHand; - CHECK_FALSE(HasRequirementWeightsInBmd(kris)); + kris.statProfile = "weapon"; + CHECK_FALSE(HasRequirementWeightsInBmd(profiles, kris)); } TEST_CASE("Bmd import reads Portuguese and Spanish names as Windows-1252 [data][items]") @@ -1868,7 +1883,7 @@ TEST_CASE("Export as bmd and Import from bmd keep the orbs', crystals' and scrol std::vector weighted; for (const ItemDefinition& item : g_ItemDatabase.GetAllSlots()) { - if (item.Exists() && HasRequirementWeightsInBmd(item) && + if (item.Exists() && HasRequirementWeightsInBmd(g_ItemDatabase.GetStatProfiles(), item) && (item.requirements.strength > 0 || item.requirements.dexterity > 0 || item.requirements.energy > 0 || item.requirements.vitality > 0)) { From a5c2229bfc96392052c291d118d1208e544bcf9e Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:24:02 +0200 Subject: [PATCH 26/32] refactor(items): one list of the exchange file's fields and tags ToClientItem listed by hand the fields ToExchangeItem copies, and both files had their own list of the shared tags; a field added to one and missed in the other would show in the diff but never be applied. CopyExchangeFields and ExchangeTags in ItemExchangeFile.h serve both directions. --- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 39 +--------- .../GameData/ItemData/ItemExchangeFile.cpp | 74 ++++++++++--------- .../Data/GameData/ItemData/ItemExchangeFile.h | 10 +++ 3 files changed, 50 insertions(+), 73 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp index 07e22ec902..d8bf0446e1 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -34,10 +34,6 @@ namespace using Data::Items::ItemDataIssue; using Data::Items::ItemDefinition; using Data::Items::ItemExchangeFile; -using Data::Items::ItemTag; - -// The tags of the exchange file; the client's other tags stay. -constexpr ItemTag SharedTags[] = {ItemTag::Ammunition, ItemTag::SecondClassQuestItem, ItemTag::ThirdClassQuestItem}; // The rule all shipped items follow: groups 0 to 5, a hand slot, two cells wide or more. bool IsTwoHanded(const ItemDefinition& item) @@ -71,39 +67,8 @@ ItemDefinition ToClientItem(const ItemDefinition* current, const ItemDefinition& ItemDefinition item = current != nullptr ? *current : ItemDefinition{}; item.group = exchange.group; item.number = exchange.number; - item.names = exchange.names; - for (const ItemTag tag : SharedTags) - { - item.tags.Set(tag, exchange.tags.Has(tag)); - } - item.width = exchange.width; - item.height = exchange.height; - item.slot = exchange.slot; - item.skill = exchange.skill; - item.level = exchange.level; - item.durability = exchange.durability; - item.magicDurability = exchange.magicDurability; - item.damageMin = exchange.damageMin; - item.damageMax = exchange.damageMax; - item.blockRate = exchange.blockRate; - item.defense = exchange.defense; - item.attackSpeed = exchange.attackSpeed; - item.walkSpeed = exchange.walkSpeed; - item.magicPower = exchange.magicPower; - item.excellentBonus = exchange.excellentBonus; - item.excellentRaisesLevel = exchange.excellentRaisesLevel; - item.requirements = exchange.requirements; - item.requirementsByLevel = exchange.requirementsByLevel; - item.classRequirements = exchange.classRequirements; - item.resistances = exchange.resistances; - item.sellValue = exchange.sellValue; - item.buyPrice = exchange.buyPrice; - item.tradable = exchange.tradable; - item.droppable = exchange.droppable; - item.storable = exchange.storable; - item.sellable = exchange.sellable; - item.personalShopSellable = exchange.personalShopSellable; - item.repairable = exchange.repairable; + // The shared fields; the client-only ones stay. + Data::Items::CopyExchangeFields(exchange, item); if (current == nullptr) { item.twoHanded = IsTwoHanded(item); diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp index 24d65ea688..858eed34d5 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp @@ -34,9 +34,6 @@ constexpr const char* Group = "group"; const Json::KeySet RootKeys = {Keys::FormatVersion, Keys::Source, Keys::Server, Keys::GameVersion, Keys::Formulas, Keys::Items}; -// The tags OpenMU has too: IsAmmunition and IsQuestItem. -constexpr ItemTag SharedTags[] = {ItemTag::Ammunition, ItemTag::SecondClassQuestItem, ItemTag::ThirdClassQuestItem}; - bool LessByKey(const ItemDefinition& left, const ItemDefinition& right) { return std::pair(left.group, left.number) < std::pair(right.group, right.number); @@ -209,45 +206,50 @@ void ExchangeReader::ReadItem(const OrderedJson& json, size_t index, std::vector } } // namespace +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; - shared.names = item.names; - for (const ItemTag tag : SharedTags) - { - shared.tags.Set(tag, item.tags.Has(tag)); - } - shared.width = item.width; - shared.height = item.height; - shared.slot = item.slot; + CopyExchangeFields(item, shared); shared.statProfile = item.statProfile.empty() ? std::string(StatProfileNames::Default) : item.statProfile; - shared.skill = item.skill; - shared.level = item.level; - shared.durability = item.durability; - shared.magicDurability = item.magicDurability; - shared.damageMin = item.damageMin; - shared.damageMax = item.damageMax; - shared.blockRate = item.blockRate; - shared.defense = item.defense; - shared.attackSpeed = item.attackSpeed; - shared.walkSpeed = item.walkSpeed; - shared.magicPower = item.magicPower; - shared.excellentBonus = item.excellentBonus; - shared.excellentRaisesLevel = item.excellentRaisesLevel; - shared.requirements = item.requirements; - shared.requirementsByLevel = item.requirementsByLevel; - shared.classRequirements = item.classRequirements; - shared.resistances = item.resistances; - shared.sellValue = item.sellValue; - shared.buyPrice = item.buyPrice; - shared.tradable = item.tradable; - shared.droppable = item.droppable; - shared.storable = item.storable; - shared.sellable = item.sellable; - shared.personalShopSellable = item.personalShopSellable; - shared.repairable = item.repairable; return shared; } diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.h b/src/source/Data/GameData/ItemData/ItemExchangeFile.h index 136b6a0488..97fc26d267 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.h +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.h @@ -52,6 +52,16 @@ struct ItemExchangeFile 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 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); From 18f0e49b70984205298e77f557fff5ee65d4866c Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:24:20 +0200 Subject: [PATCH 27/32] perf(editor): the export preview compares only the formulas to count them PreviewItemSyncExport built a whole diff, flattening and comparing all 911 items of both sides, only to count the formula rows right after it had compared the items itself; CountFormulaDifferences compares the formulas alone. --- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 4 ++-- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 7 +++++++ src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp index d8bf0446e1..c96b7f90a0 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -284,9 +284,9 @@ ItemSyncExportPreview PreviewItemSyncExport(const std::filesystem::path& path) fileKeys.erase(key); } preview.removed = static_cast(fileKeys.size()); - if (file.formulas) + if (file.formulas && client.formulas) { - preview.formulaEntries = static_cast(BuildItemSyncDiff(client, file, SyncOptions{}).formulas.size()); + preview.formulaEntries = CountFormulaDifferences(*file.formulas, *client.formulas); } return preview; } diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index 69473868d0..1c64191054 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -676,6 +676,13 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang 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) { diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 58bff203d8..6b67cad863 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -130,6 +130,9 @@ struct SyncOptions 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, From 2cae23d1c38dbbacafdbac108c138fdd64611def Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:25:03 +0200 Subject: [PATCH 28/32] refactor(items): one index of an exchange file's items ApplyItemSyncImport scanned the applied items once per row, and the diff and the export preview each built their own index; IndexExchangeItems in ItemExchangeFile.h serves all three. --- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 16 ++++------------ src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 16 +++------------- .../Data/GameData/ItemData/ItemExchangeFile.cpp | 10 ++++++++++ .../Data/GameData/ItemData/ItemExchangeFile.h | 5 +++++ 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp index c96b7f90a0..6a6292773c 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -48,12 +48,6 @@ std::string RuleName(const std::string& profile) return profile == Data::Items::StatProfileNames::Default ? std::string() : profile; } -const ItemDefinition* FindItem(const ItemExchangeFile& file, int group, int number) -{ - const auto item = std::ranges::find_if(file.items, [&](const ItemDefinition& entry) - { return entry.group == group && entry.number == number; }); - return item != file.items.end() ? &*item : nullptr; -} } // namespace ItemExchangeFile BuildClientExchange() @@ -122,6 +116,7 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, std::vector items; std::vector removed; + 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); @@ -133,7 +128,8 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, } continue; } - const ItemDefinition* exchange = FindItem(applied, row.group, row.number); + 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)) { @@ -257,11 +253,7 @@ ItemSyncExportPreview PreviewItemSyncExport(const std::filesystem::path& path) preview.fileHasFormulas = file.formulas.has_value(); const ItemExchangeFile client = BuildClientExchange(); - std::map, const ItemDefinition*> fileItems; - for (const ItemDefinition& item : file.items) - { - fileItems[{item.group, item.number}] = &item; - } + 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) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index 1c64191054..a71a501056 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -138,16 +138,6 @@ bool TakesPart(const std::string& path, bool incomingHasIt, const SyncOptions& o return std::ranges::find(options.uiLocales, locale) != options.uiLocales.end(); } -std::map IndexItems(const ItemExchangeFile& file) -{ - std::map items; - for (const ItemDefinition& item : file.items) - { - items[{item.group, item.number}] = &item; - } - return items; -} - bool IsHeld(const SyncOptions& options, const ItemKey& key) { return options.isHeld && options.isHeld(Data::Items::MakeItemType(key.first, key.second)); @@ -607,8 +597,8 @@ ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchang diff.takenLocales.assign(options.uiLocales.begin(), options.uiLocales.end()); const ItemExchangeFile& current = client; const ItemExchangeFile& incoming = file; - const std::map currentItems = IndexItems(current); - const std::map incomingItems = IndexItems(incoming); + 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()); @@ -688,7 +678,7 @@ ItemExchangeFile ApplyItemSyncDiff(const ItemExchangeFile& client, const ItemExc { ItemExchangeFile target = client; const ItemExchangeFile& source = file; - const std::map sourceItems = IndexItems(source); + const Data::Items::ExchangeItemIndex sourceItems = Data::Items::IndexExchangeItems(source); std::map targetItems; for (const ItemDefinition& item : target.items) { diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp index 858eed34d5..892ff4956e 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.cpp @@ -206,6 +206,16 @@ void ExchangeReader::ReadItem(const OrderedJson& json, size_t index, std::vector } } // 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; diff --git a/src/source/Data/GameData/ItemData/ItemExchangeFile.h b/src/source/Data/GameData/ItemData/ItemExchangeFile.h index 97fc26d267..0cf17fc2d0 100644 --- a/src/source/Data/GameData/ItemData/ItemExchangeFile.h +++ b/src/source/Data/GameData/ItemData/ItemExchangeFile.h @@ -6,6 +6,7 @@ #include "Data/GameData/ItemData/ItemDefinition.h" #include "Data/GameData/ItemData/ItemStatFile.h" +#include #include #include #include @@ -62,6 +63,10 @@ inline constexpr ItemTag ExchangeTags[] = {ItemTag::Ammunition, ItemTag::SecondC // 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); From f7ede1220b9441abc010f056567e612c240d622a Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:25:24 +0200 Subject: [PATCH 29/32] perf(editor): Item Sync builds the field list of "A field in all items" with the diff It built a set of every changed field's name on each frame while the window was open; the list only changes with the diff, so Compare builds it once. --- src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp | 33 ++++++++++++---------- src/MuEditor/UI/ItemTools/MuItemSyncUI.h | 3 ++ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp index 34ad52e3e2..be06aeb92d 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.cpp @@ -470,6 +470,7 @@ void CMuItemSyncUI::RenderExportResult() 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(); @@ -500,11 +501,24 @@ void CMuItemSyncUI::Compare(const std::filesystem::path& path) 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; @@ -795,29 +809,18 @@ void CMuItemSyncUI::RenderSummary() void CMuItemSyncUI::RenderFieldToggle() { - 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); - } - } - } - if (names.empty()) + if (m_toggleFields.empty()) { return; } - if (!names.contains(m_toggleField)) + if (std::ranges::find(m_toggleFields, m_toggleField) == m_toggleFields.end()) { - m_toggleField = *names.begin(); + m_toggleField = m_toggleFields.front(); } ImGui::SetNextItemWidth(Scaled(FieldColumnWidth)); if (ImGui::BeginCombo(I18N::Editor::AFieldInAllItems, m_toggleField.c_str())) { - for (const std::string& name : names) + for (const std::string& name : m_toggleFields) { if (ImGui::Selectable(name.c_str(), name == m_toggleField)) { diff --git a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h index 958fc21467..f83a259fd8 100644 --- a/src/MuEditor/UI/ItemTools/MuItemSyncUI.h +++ b/src/MuEditor/UI/ItemTools/MuItemSyncUI.h @@ -157,6 +157,9 @@ class CMuItemSyncUI // 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; From d0a413a934ee97cbb1d66cffc27708ddd0aa7885 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:25:40 +0200 Subject: [PATCH 30/32] refactor(editor): the Item Sync diff no longer keeps the languages it takes takenLocales was filled and never read; the options say which languages are taken. --- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 1 - src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index a71a501056..f6978f4f3a 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -594,7 +594,6 @@ std::string WriteKnownDifferencesJson(const KnownDifferences& known) ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, const SyncOptions& options) { ItemSyncDiff diff; - diff.takenLocales.assign(options.uiLocales.begin(), options.uiLocales.end()); const ItemExchangeFile& current = client; const ItemExchangeFile& incoming = file; const Data::Items::ExchangeItemIndex currentItems = Data::Items::IndexExchangeItems(current); diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 6b67cad863..60c8f6628d 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -93,8 +93,6 @@ struct ItemSyncDiff { std::vector items; std::vector formulas; - // The languages names are taken in (the client's UI languages). - std::vector takenLocales; // 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. From 4f07b4e84b86248a20d93754df1065cf50e483ad Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:26:35 +0200 Subject: [PATCH 31/32] refactor(editor): ApplyItemSyncImport in functions that do one thing each It was about 135 lines: it applied the diff, picked the stat file, collected the items and removals, checked the held items, ran the save checks and recorded the undo step. FindChangedStatFile, CollectImportChange, CheckHeldItems, CheckImportedData and RecordImport do those now; the behaviour stays. --- src/MuEditor/UI/ItemTools/ItemSyncApply.cpp | 139 +++++++++++++------- 1 file changed, 89 insertions(+), 50 deletions(-) diff --git a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp index 6a6292773c..549d9dfb45 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncApply.cpp @@ -93,29 +93,36 @@ std::vector DescribeNewItem(const ItemDefinition& exchange) return notes; } -bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, std::vector& issues) +namespace { - 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; - } +using Data::Items::ItemStatFile; +using Data::Items::ItemStatProfiles; - // The formulas; the client's profile rules stay. - std::optional statFile; - if (applied.formulas && client.formulas && !(*applied.formulas == *client.formulas)) +// 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) { - statFile = *applied.formulas; - statFile->profileRules = g_ItemDataHandler.GetStatFile().profileRules; + return std::nullopt; } - const Data::Items::ItemStatProfiles profiles = - statFile ? Data::Items::ItemStatProfiles(*statFile) : g_ItemDatabase.GetStatProfiles(); + ItemStatFile statFile = *applied.formulas; + statFile.profileRules = g_ItemDataHandler.GetStatFile().profileRules; + return statFile; +} - std::vector items; - std::vector removed; +// 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) { @@ -124,7 +131,7 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, { if (row.checked && row.locked.empty()) { - removed.push_back(itemType); + change.removed.push_back(itemType); } continue; } @@ -135,41 +142,43 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, { continue; } - items.push_back(ToClientItem(current, *exchange, profiles)); + 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. - std::vector heldIssues; - for (const ItemDefinition& item : items) +// 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)) { - heldIssues.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"}); + 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 : removed) + for (const int itemType : change.removed) { if (IsHeldByCharacter(itemType)) { - heldIssues.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"}); + 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"}); } } - if (!heldIssues.empty()) - { - issues.insert(issues.end(), heldIssues.begin(), heldIssues.end()); - return false; - } +} - // The checks of a save, on the data as it would be. +// 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()) { @@ -178,7 +187,7 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, candidates[Data::Items::MakeItemType(item.group, item.number)] = item; } } - for (const ItemDefinition& item : items) + for (const ItemDefinition& item : change.items) { ItemDefinition candidate = item; if (candidate.statProfile.empty()) @@ -187,7 +196,7 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, } candidates[Data::Items::MakeItemType(item.group, item.number)] = std::move(candidate); } - for (const int itemType : removed) + for (const int itemType : change.removed) { candidates.erase(itemType); } @@ -203,14 +212,15 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, { Data::Items::ValidateItemStatsAsWritten(*statFile, checkIssues); } - if (Data::Items::HasErrors(checkIssues)) - { - std::ranges::copy_if(checkIssues, std::back_inserter(issues), [](const ItemDataIssue& issue) - { return issue.severity == Data::Items::ItemDataIssueSeverity::Error; }); - return false; - } + std::ranges::copy_if(checkIssues, std::back_inserter(issues), [](const ItemDataIssue& issue) + { return issue.severity == Data::Items::ItemDataIssueSeverity::Error; }); +} - // One step of the undo history. +// 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) @@ -219,16 +229,45 @@ bool ApplyItemSyncImport(const ItemSyncDiff& diff, const ItemExchangeFile& file, FindStatProfileChanges(g_ItemDatabase.GetAllSlots(), g_ItemDatabase.GetStatProfiles(), profiles); g_ItemEdits.SetStatFile(label, *statFile, ApplyStatProfileChanges(g_ItemDatabase.GetAllSlots(), changes)); } - if (!items.empty()) + if (!change.items.empty()) { // ToClientItem decided each item's profile: "" follows the rules, another one is a pick. - g_ItemEdits.SetItems(label, items, Data::Items::StatProfileStore::AsGiven); + g_ItemEdits.SetItems(label, change.items, Data::Items::StatProfileStore::AsGiven); } - for (const int itemType : removed) + 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; } From 99c4203dee6492381b67c64d1acbb18a0b7ddc77 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Fri, 23 Oct 2026 14:27:26 +0200 Subject: [PATCH 32/32] refactor(editor): KnownDifferences in files of its own It has its own file, format version, loader and writer, but lived in the diff's header and source; KnownDifferences.h and .cpp hold it now, as rule 6 of docs/CODING_RULES.md asks. --- src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp | 97 -------------- src/MuEditor/UI/ItemTools/ItemSyncDiff.h | 21 +-- .../UI/ItemTools/KnownDifferences.cpp | 122 ++++++++++++++++++ src/MuEditor/UI/ItemTools/KnownDifferences.h | 36 ++++++ 4 files changed, 160 insertions(+), 116 deletions(-) create mode 100644 src/MuEditor/UI/ItemTools/KnownDifferences.cpp create mode 100644 src/MuEditor/UI/ItemTools/KnownDifferences.h diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp index f6978f4f3a..253b17726a 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.cpp @@ -20,7 +20,6 @@ namespace MuEditor::Items namespace { using Data::Items::ItemDataIssue; -using Data::Items::ItemDataIssueSeverity; using Data::Items::ItemDefinition; using Data::Items::ItemExchangeFile; using Data::Items::Json::OrderedJson; @@ -29,7 +28,6 @@ using Data::Items::Json::OrderedJson; using Fields = std::map>; using ItemKey = std::pair; -constexpr int KnownDifferencesFormatVersion = 1; constexpr const char* NameField = "name"; constexpr const char* TagsField = "tags"; constexpr const char* LevelField = "level"; @@ -496,101 +494,6 @@ void ApplyFormulas(const ItemExchangeFile& source, ItemExchangeFile& target, con } } // 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"; -} - ItemSyncDiff BuildItemSyncDiff(const ItemExchangeFile& client, const ItemExchangeFile& file, const SyncOptions& options) { ItemSyncDiff diff; diff --git a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h index 60c8f6628d..6d74614973 100644 --- a/src/MuEditor/UI/ItemTools/ItemSyncDiff.h +++ b/src/MuEditor/UI/ItemTools/ItemSyncDiff.h @@ -2,6 +2,8 @@ #ifdef _EDITOR +#include "KnownDifferences.h" + #include "Data/GameData/ItemData/ItemDataIssue.h" #include "Data/GameData/ItemData/ItemExchangeFile.h" @@ -25,25 +27,6 @@ // export writes the client's data without a diff (ItemSyncApply.h). 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); - struct SyncField { std::string field; 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