From 99df61a2b410e0ab95fca2267343092e34292fc8 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 12 Nov 2026 02:02:04 +0100 Subject: [PATCH 1/3] feat(npcs): NPCs talk from their data: talk fields, steps and the NPC rules tools (MN5) What the client does when the hero talks to an NPC now comes from the NPC's entry in Data/Monsters/Monsters.json instead of the number and model checks in MOVEMENT_TALK (D66, Q-MN46 to Q-MN48). Talk goes through GameLogic::Npcs::Talk and a TalkWorld, recorded by tests/monsters/test_npc_talk.cpp (D50). - Talk fields: talks, talkLevel (with "%d"), repairShop, crywolfContract (the 0x07 Crywolf hero contract rule, on the 13 entries the old model list named) and onTalk, the steps in order: send talk or petInfo, dialog (title, lines, an OK sending enterRefuge or enterBarracks by quest state and zen), message, questWindow (noQuestTitleFor) and crywolfAltarContract. - Shared talk texts: a talkTexts section; the five Crywolf altars' step names the set crywolfAltar. Crywolf's elf time is the state crywolfElfTime; the Werewolf Guardsman 407 only talks then by his state variant. - Texts translatable like the names, copied from the game's texts in its 11 languages that have them. - Fixes: P23 (a Little Santa's message overflowed its buffer), the six Little Santas without a message no longer log an empty line. The Gatekeeper dialog is 408's own, and the Delgado/Lugard/Julia/David list is gone (D66). - Editor: Monster Setup's NPC rules section and step editor, a Shared talk texts section, Monster Translations' Talk texts tab, and Data Checks warnings (missing languages, an OK quest without a state, an altar step on another model or naming no set). - Docs: docs/monster-data.md "Talking to NPCs" and "Shared talk texts". --- docs/monster-data.md | 80 +++- .../2026-10-23-data-driven-monsters-design.md | 2 +- src/Localization/Editor.de.resx | 120 +++++ src/Localization/Editor.en.resx | 120 +++++ .../UI/MonsterTools/MonsterChecks.cpp | 6 + .../UI/MonsterTools/MonsterEditHistory.cpp | 10 +- .../UI/MonsterTools/MonsterEditHistory.h | 7 + src/MuEditor/UI/MonsterTools/MonsterEdits.cpp | 25 +- src/MuEditor/UI/MonsterTools/MonsterEdits.h | 3 + .../UI/MonsterTools/MonsterSetupFields.cpp | 6 + .../UI/MonsterTools/MonsterSetupFields.h | 1 + .../UI/MonsterTools/MonsterSetupLists.cpp | 319 +++++++++++++ .../UI/MonsterTools/MonsterSetupLists.h | 6 + .../UI/MonsterTools/MonsterTalkTextRows.cpp | 271 +++++++++++ .../UI/MonsterTools/MonsterTalkTextRows.h | 62 +++ .../UI/MonsterTools/MuMonsterSetupUI.cpp | 153 ++++++- .../UI/MonsterTools/MuMonsterSetupUI.h | 8 + .../MonsterTools/MuMonsterTranslationsUI.cpp | 27 +- .../UI/MonsterTools/MuMonsterTranslationsUI.h | 6 +- .../UI/MonsterTools/TalkStepStates.cpp | 101 +++++ src/MuEditor/UI/MonsterTools/TalkStepStates.h | 57 +++ src/bin/Data/Monsters/Monsters.json | 386 ++++++++++++++++ .../MonsterData/MonsterDataHandler.cpp | 12 +- .../MonsterData/MonsterJsonStorage.cpp | 12 +- .../MonsterData/MonsterJsonStorage.h | 7 +- .../MonsterData/MonsterDataValidation.cpp | 103 ++++- .../MonsterData/MonsterDataValidation.h | 17 +- .../GameData/MonsterData/MonsterDatabase.h | 13 + .../GameData/MonsterData/MonsterDefinition.h | 21 + .../GameData/MonsterData/MonsterSetup.cpp | 57 ++- .../Data/GameData/MonsterData/MonsterSetup.h | 158 +++++++ .../GameData/MonsterData/MonsterSetupJson.cpp | 325 +++++++++++++- .../GameData/MonsterData/MonstersJson.cpp | 123 ++++- .../Data/GameData/MonsterData/MonstersJson.h | 17 +- src/source/Engine/Object/ZzzInterface.cpp | 424 ++++++++---------- src/source/Engine/Object/ZzzInterface.h | 1 - src/source/GameLogic/NPCs/NpcTalk.cpp | 163 +++++++ src/source/GameLogic/NPCs/NpcTalk.h | 89 ++++ src/source/GameLogic/Quests/CSQuest.cpp | 6 +- src/source/Network/Server/WSclient.cpp | 12 +- src/source/UI/Quests/NPCQuest.cpp | 9 +- .../World/GameMaps/MonsterSpawnWorld.cpp | 3 + tests/data/test_monster_data_files.cpp | 6 +- tests/data/test_monster_json.cpp | 126 ++++++ tests/data/test_monster_setup_json.cpp | 124 ++++- tests/data/test_monster_tool_windows.cpp | 220 +++++++++ .../data/test_monster_translations_window.cpp | 94 +++- tests/monsters/CMakeLists.txt | 1 + tests/monsters/test_monster_setup.cpp | 50 +++ tests/monsters/test_npc_talk.cpp | 393 ++++++++++++++++ 50 files changed, 4063 insertions(+), 299 deletions(-) create mode 100644 src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.cpp create mode 100644 src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.h create mode 100644 src/MuEditor/UI/MonsterTools/TalkStepStates.cpp create mode 100644 src/MuEditor/UI/MonsterTools/TalkStepStates.h create mode 100644 src/source/GameLogic/NPCs/NpcTalk.cpp create mode 100644 src/source/GameLogic/NPCs/NpcTalk.h create mode 100644 tests/monsters/test_npc_talk.cpp diff --git a/docs/monster-data.md b/docs/monster-data.md index 7bc5c41747..6189913184 100644 --- a/docs/monster-data.md +++ b/docs/monster-data.md @@ -245,7 +245,12 @@ any character. | `moveSpeed` | Its walk speed, 0 to 65535 | | `noBloodPool` | Leaves no blood pool when it dies | | `hooks` | Code of the maps it hands over to when it appears, with the values that code fixed (Q-MN36d): `[{"hook": "EmpireGuardianRageReset", "boss": "Raymond"}]`. The hooks: `RaklionBossDrop` (Selupan drops down from where he waits), `Kanturu2ndEntrance` (Kanturu's entrance window takes the Gateway Machine, whose animations and the way in it runs), `EmpireGuardianRageReset` with `boss` `Raymond`, `Ercanne`, `Daesuler`, `Gallia`, `Bermont`, `Kato`, `Gaion` or `Jerint` (a new boss starts without the rage before it), `IllusionTempleStorageReset` with `storage` `allied` or `illusion` (a new storage shows no point effect its team had). A hook or value the maps do not know is an Error and does nothing | -| `stateVariants` | Fields that are other while a state of the game is on, each replacing the monster's: `[{"state": "castleSiegeNotRunning", "heightOffset": -100}]`. The states: `castleSiegeRunning`, `castleSiegeNotRunning`, `raklionBossWaiting` (Selupan above the map, from standby to ready), `playerTransformed` (a player transformed into the monster comes into view, packet 0x45: the Giant is smaller then), `crywolfReady` (Crywolf's battle is about to start: its monsters come in at once, without the thunder). A state variant changes fields and clears none, and has no state variants of its own. A map variant's `stateVariants` replace the monster's on its maps | +| `stateVariants` | Fields that are other while a state of the game is on, each replacing the monster's: `[{"state": "castleSiegeNotRunning", "heightOffset": -100}]`. The states: `castleSiegeRunning`, `castleSiegeNotRunning`, `raklionBossWaiting` (Selupan above the map, from standby to ready), `playerTransformed` (a player transformed into the monster comes into view, packet 0x45: the Giant is smaller then), `crywolfReady` (Crywolf's battle is about to start: its monsters come in at once, without the thunder), `crywolfElfTime` (on Crywolf only elves may make the altar contracts: the Werewolf Guardsman 407 only talks then). A state variant changes fields and clears none, and has no state variants of its own. A map variant's `stateVariants` replace the monster's on its maps | +| `talks` | Whether the NPC talks when the hero clicks it (MN5, D66): `false` for the Wolf Status 204; every other NPC talks | +| `talkLevel` | The level the hero needs to talk to it, and the message below it, with `%d` for the level: `{"level": 10, "message": {"en": "Only level above %d can do the Chaos Combination."}}` (the Chaos Goblin 238). Below it the hero gets the message and keeps the NPC as the target | +| `repairShop` | Its shop shows the repair buttons: the five repair shops 243, 246, 251, 416 and 578 | +| `onTalk` | What talking to it does, in order ([Talking to NPCs](#talking-to-npcs)); without it the NPC sends the talk request | +| `crywolfContract` | It takes part in Crywolf's hero contract: a buff the server sends again while it shows it (0x07) toggles the contract and takes the other Crywolf states off (the altars 205-209 and the NPCs 226, 230, 240, 248, 250, 251, 256, 478) | A value of the wrong kind is fatal; a joint, object, buff or standing the code does not have is an Error, and the character appears without it. @@ -261,6 +266,61 @@ map its entry names, with each state its state variants name on and off, in a new slot, a slot another character left and the slot of a living character with its key, through the packets and the cutscenes. +### Talking to NPCs + +When the hero talks to an NPC, the client checks `talks` and `talkLevel`, +sets the shop's repair buttons by `repairShop`, closes the quest window and +asks the legacy quest state, then runs the NPC's `onTalk` steps in order +(MN5, D66, Q-MN46). The server answers the talk request with the NPC's window. + +```json +"onTalk": [ + {"dialog": {"title": {"en": "Werewolf Guardsman"}, "lines": [{"en": "..."}], + "ok": {"request": "enterRefuge", "if": {"quest": "thirdChangeUp2", "is": ["ongoing", "done"]}, + "zen": 3000000, "failure": {"en": "You are short of Zen."}}}}, + {"send": "talk"} +] +``` + +| Step | What it does | +|---|---| +| `{"send": "talk"}` | Sends the talk request, as the map lets it: on Kanturu 2nd not while the gate machine animates, in the Illusion Temple only to the event's NPCs (their code stays) | +| `{"send": "petInfo"}` | Asks the server for the equipped Dark Horse's and Dark Raven's information (the Pet Trainer 226) | +| `{"dialog": ...}` | Shows a dialog: its bold `title`, its `lines`, and an `ok` that sends a `request` (`enterRefuge`, `enterBarracks`) when the `quest` (`changeUp1` ... `thirdChangeUp3`) is in one of the states of `is` (`none`, `ongoing`, `done`, `no`, `ready`, `error`) and the hero has the `zen`, else its `failure` message | +| `{"message": ...}` | Logs a system message (the red and blue Little Santas 470 and 471) | +| `{"questWindow": {"noQuestTitleFor": [...]}}` | The quest window shows the NPC's own name and no quest title for these classes (Marlon 229 for the Dark Lord, Magic Gladiator and Rage Fighter, whose quest is another NPC's) | +| `{"crywolfAltarContract": "crywolfAltar"}` | A Crywolf altar's contract (205-209), which the map's code makes while only elves may, with the texts of the shared set it names ([Shared talk texts](#shared-talk-texts)). On Crywolf otherwise it does nothing; elsewhere it sends the talk request | + +The Werewolf Guardsman 407 shows his dialog and talks, the Gatekeeper 408 +talks and shows his. The texts are translatable like the names, copied from +the game's texts in the 11 languages that have them (the game's zh-CN texts +have none of them yet); Data Checks warns about a text missing one of the +game's languages (it shows the English one there), about an OK whose quest +has no state in `is` (it never sends) and about a `crywolfAltarContract` +step on a model that is no Crywolf altar (it only talks). While only elves +may make the Crywolf altar contracts (the state `crywolfElfTime`), an altar +runs its `crywolfAltarContract` step and the Werewolf Guardsman 407 only +talks, by his state variant; every other NPC talks as always. + +#### Shared talk texts + +Talk texts several NPCs share are defined once, by name, in `talkTexts` of +`Monsters.json`, and a step names its set. The five altars' contract step +names `crywolfAltar`, whose texts are `canContract` (`%d` for the contracts +left), `proceed`, `onMount`, `needGuardian`, `destroyed` and `ongoing`: + +```json +"talkTexts": { + "crywolfAltar": {"canContract": {"en": "Contract can be made for %d times."}, "proceed": {"en": "..."}, ...} +} +``` + +A step naming a set the list does not have, and a set missing one of its +texts, are fatal when the list is read; in the editor Data Checks reports a +step naming a set that is not there as an error (the altar shows empty +texts, Save refuses it). The sets' texts are translatable like the others +and Data Checks warns about their missing languages. + ### Unknown monsters A monster the game cannot show as itself gets a bug look instead of the Bull @@ -833,6 +893,11 @@ item tools. monster's row; see [translation-system.md → In the monster editor](translation-system.md#in-the-monster-editor) and [Import and export of NpcName txt](#import-and-export-of-npcname-txt). + The tab **Talk texts** has a row per text of the shared talk texts + ("talkTexts.crywolfAltar.canContract"), then per text of an NPC's talk, its + map and state variants' included ("408 onTalk[1].dialog.lines[0]", + "205 mapVariants[0].onTalk[0].message"), with a column per language and the + same missing-language filter; an edit is an undo step. - **Monster Model**: the model of the selected monster, from the model list: its slot and range (hover over the range for what the code does with it), the monsters with the model (a click selects one), and @@ -1013,7 +1078,18 @@ item tools. are edited under their label once set. The equipment shows its count and **Open Monster Equipment**. Below them, always shown, the section **Unknown monsters** edits the bug look of every monster the game cannot - show as itself ([Unknown monsters](#unknown-monsters)). + show as itself ([Unknown monsters](#unknown-monsters)), and **Shared talk + texts** the sets of [Shared talk texts](#shared-talk-texts): each set's + texts in English with **x**, and **Add a set** with its name. The section **NPC + rules** has `talks`, the minimum level to talk with its message, + `repairShop`, `crywolfContract` and **On talk, in order**: a row per step, + opened to its kind and values (a dialog's title, lines with **Add a line**, + and **OK sends a request** with its request, quest and states, zen and the + text without the zen, and a red hint while a quest has no state ticked; a + message; the classes of a quest window; the shared set an altar contract + shows), + with **Move up**, **Move down**, **x** and **Add a step**. Texts are typed + in English there; the other languages are in Monster Translations. - **Monster Equipment**: what the selected monster holds and wears, laid out as the game's character window (helm and wings on top; weapon 1, armor, weapon 2 and helper; gloves, pants and boots). A click on a slot diff --git a/docs/superpowers/specs/2026-10-23-data-driven-monsters-design.md b/docs/superpowers/specs/2026-10-23-data-driven-monsters-design.md index e0037daf35..7902573ca8 100644 --- a/docs/superpowers/specs/2026-10-23-data-driven-monsters-design.md +++ b/docs/superpowers/specs/2026-10-23-data-driven-monsters-design.md @@ -926,7 +926,7 @@ model entries. | MNG | One monster, several numbers | MN2b | D61: the grouped format (merge and difference), the one-time conversion (36 monsters with 160 numbers) with its comparison, `maps` per number from OpenMU's Season 6 placements (430 numbers, with the event spawns of Kanturu, Raklion, Crywolf, the Imperial Guardian and the Castle Siege), the Browser tree and its number actions, shared and own values in every monster tool. The 13 numbers of P26 keep the generic pattern as their own `attacks`. | | MN3 | Models | MN1, MN.P | `Data/Monsters/Models.json` with what `OpenMonsterModel`, `OpenNpc`, the creators' model constants and the slot-dependent code in `CreateCharacterPointer` (with the Hellas and Battle Castle hooks) set; part models by FX1 name; sounds with ids. `model` is filled for every monster for the tools; the creators still choose the model until MN4, and the recorder checks both agree. Monster recorder, model part. Editor: Monster Model, Monster Preview. P1, P9. | | MN4 | Setup at spawn | MN3, MN.P, MNG | The values of `CreateMonster` and the creators into the monster entries (D62, D63); the creators keep only logic, as hooks (D49); the post-creation changes of the packet handlers as Q-MN38 decided; `CDirection::SummonCreateMonster` takes the model from the monster table. Three parts in PR 3b: **MN4a** the core switch, **MN4b** the map creators, **MN4c** player-shaped monsters and NPCs and the merchants' part models, with the item tools learning monster references (D60). Monster recorder, creation part. Then the removal commits and one fix commit each: the bug look instead of the Bull Fighter (D64), P2, P13, P14, P27, Gayion's swords as parts, Maya without the Bull Fighter model, Molt once per map (Q-MN45). Editor: Monster Setup, Monster Equipment, the Preview additions. P2, P13–P15, P17, P24, P27. | -| MN5 | NPC rules | MN4 | P23 fixed first; the talk rules by number into the monster entries as `talks`, `talkLevel`, `repairShop` and talk blocks (D66), replacing the code of Marlon, the Pet Trainer, the Werewolf Guardsman and the barracks Gatekeeper; the Crywolf altar contract stays a hook; the models that 0x07 runs the Crywolf hero contract rule for (`ReceiveSkillStatus` for a buff shown already: the altars, `MODEL_SMITH`, `MODEL_NPC_SERBIS`, `MODEL_MERCHANT_MAN`, `MODEL_STORAGE`, `MODEL_NPC_BREEDER`; its always-true condition fixed in sven-n/MuMain#1081) become a field or that hook of their entries, so an entry's model change no longer switches the rule; texts as translatable strings. Monster recorder, talk part. Editor: NPC rules in Monster Setup, texts in Monster Translations. P23. | +| MN5 | NPC rules | MN4 | P23 fixed first; the talk rules by number into the monster entries as `talks`, `talkLevel`, `repairShop` and talk blocks (D66), replacing the code of Marlon, the Pet Trainer, the Werewolf Guardsman and the barracks Gatekeeper; the Crywolf altar contract stays a hook; the models that 0x07 runs the Crywolf hero contract rule for (`ReceiveSkillStatus` for a buff shown already: the altars, `MODEL_SMITH`, `MODEL_NPC_SERBIS`, `MODEL_MERCHANT_MAN`, `MODEL_STORAGE`, `MODEL_NPC_BREEDER`; its always-true condition fixed in sven-n/MuMain#1081) become their entries' field `crywolfContract`, so an entry's model change no longer switches the rule (done in 3c, with the altars' contract as their `crywolfAltarContract` talk step); texts as translatable strings. Monster recorder, talk part. Editor: NPC rules in Monster Setup, texts in Monster Translations. P23. | | MN6 | OpenMU sync, client side | MN1, MN2, items 7 | Exchange of number and names (D53); the read-only kind, window and skill data for the checks; the skill check against `attacks`. | | MN-B | OpenMU sync, server side | MN6 (file format) | OpenMU: monsters in the exchange pages of items phase B, with the event monster skills collected from settings and code. | | MN7 | Sounds per animation | MN3 | The global and the 7 map `PlayMonsterSound` hooks (with the 500-unit cut-off), the trap sounds of `SetPlayerAttack` and the per-map sound loads become sound events of the models. Monster recorder, sound part. | diff --git a/src/Localization/Editor.de.resx b/src/Localization/Editor.de.resx index 73c7e0d5af..cd343e486a 100644 --- a/src/Localization/Editor.de.resx +++ b/src/Localization/Editor.de.resx @@ -5263,4 +5263,124 @@ Augen brauchen 2, 4 oder 6 Knochen (rechts, links und bis zu zwei weitere Paare), jeweils 0 oder mehr; bis dahin speichert Speichern die Monster nicht. + + NPC-Regeln + + + Spricht + + + Mindeststufe zum Sprechen + + + Reparaturladen + + + Beim Sprechen, der Reihe nach + + + Dialog + + + Gesprächsanfrage senden + + + Haustier-Informationen erfragen + + + Questfenster ohne Questtitel, für + + + Anfrage + + + Wenn die Quest + + + ist in + + + Kosten in Zen + + + Ohne das Zen + + + Titel + + + Zeile hinzufügen + + + OK sendet eine Anfrage + + + Einen Zustand ankreuzen: ohne ihn sendet OK nichts. + + + Schritt hinzufügen + + + Die Texte hier sind Englisch; die anderen Sprachen stehen in Monster Translations. + + + Namen + + + Gesprächstexte + + + Alle Gesprächstexte + + + Gesprächstext {1} von {0} in {2} + + + Der Gesprächstext wurde nicht gespeichert. + + + Crywolf-Altarvertrag + + + Verbleibende Verträge (%d) + + + Die Frage + + + Auf einem Reittier + + + Bei Abbrechen + + + Altar zerstört + + + Vertrag läuft + + + Im Crywolf-Heldenvertrag + + + Geteilte Gesprächstexte + + + Gesprächstexte, die mehrere NPCs teilen, mit Namen: der Altarvertrags-Schritt von Crywolf nennt seinen Satz. Hier auf Englisch; die anderen Sprachen sind in Monster Translations. + + + Satz hinzufügen + + + Name des Satzes + + + Texte des Satzes + + + Die Texte werden in Geteilte Gesprächstexte bearbeitet. + + + Ein Gesprächsschritt nennt diesen Satz: er bleibt. + diff --git a/src/Localization/Editor.en.resx b/src/Localization/Editor.en.resx index 104ec00709..078f7cae36 100644 --- a/src/Localization/Editor.en.resx +++ b/src/Localization/Editor.en.resx @@ -5479,4 +5479,124 @@ Eyes need 2, 4 or 6 bones (right, left and up to two more pairs), 0 or more each; until then Save refuses the monsters. + + NPC rules + + + Talks + + + Minimum level to talk + + + Repair shop + + + On talk, in order + + + Dialog + + + Send the talk request + + + Ask for the pet information + + + Quest window without its title, for + + + Request + + + If the quest + + + is in + + + Zen it costs + + + Without the zen + + + Title + + + Add a line + + + OK sends a request + + + Tick a state: without one the OK sends nothing. + + + Add a step + + + The texts here are English; the other languages are in Monster Translations. + + + Names + + + Talk texts + + + All talk texts + + + talk text {1} of {0} in {2} + + + The talk text was not stored. + + + Crywolf altar contract + + + Contracts left (%d) + + + The question + + + On a mount + + + On Cancel + + + Altar destroyed + + + Contract ongoing + + + In the Crywolf hero contract + + + Shared talk texts + + + Talk texts several NPCs share, by name: a Crywolf altar contract step names its set. Typed in English here; the other languages are in Monster Translations. + + + Add a set + + + Set name + + + Texts of the set + + + The texts are edited in Shared talk texts. + + + A talk step names this set: it stays. + diff --git a/src/MuEditor/UI/MonsterTools/MonsterChecks.cpp b/src/MuEditor/UI/MonsterTools/MonsterChecks.cpp index 4d37c535a4..e5e8f9ad6a 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterChecks.cpp +++ b/src/MuEditor/UI/MonsterTools/MonsterChecks.cpp @@ -8,6 +8,7 @@ #include "MonsterGroupChecks.h" #include "Data/DataHandler/MonsterData/MonsterDataHandler.h" +#include "I18N/All.h" #include "Data/DataHandler/MonsterData/MonsterJsonStorage.h" #include "Data/GameData/ItemData/ItemDatabase.h" #include "Data/GameData/MonsterData/MonsterDataValidation.h" @@ -48,6 +49,11 @@ void RunMonsters(std::vector& issues) GroupChecks::Validate(issues); ValidateItems(issues); Data::Monsters::ValidateMonsterSetups(AllMonsters(), Data::Monsters::GetMonstersFile().generic_string(), issues); + Data::Monsters::ValidateMonsterTalkSteps(AllMonsters(), g_MonsterDatabase.GetTalkTexts(), + Data::Monsters::GetMonstersFile().generic_string(), issues); + Data::Monsters::ValidateMonsterTalkTexts(AllMonsters(), g_MonsterDatabase.GetTalkTexts(), + I18N::GetAvailableLocales(), + Data::Monsters::GetMonstersFile().generic_string(), issues); } void RunModels(std::vector& issues) diff --git a/src/MuEditor/UI/MonsterTools/MonsterEditHistory.cpp b/src/MuEditor/UI/MonsterTools/MonsterEditHistory.cpp index d1f9b6b574..fb653c94c0 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterEditHistory.cpp +++ b/src/MuEditor/UI/MonsterTools/MonsterEditHistory.cpp @@ -20,7 +20,7 @@ MonsterEditHistory::MonsterEditHistory(Hooks hooks) : m_hooks(std::move(hooks)) void MonsterEditHistory::Record(MonsterEditEntry entry) { if (entry.steps.empty() && entry.models.empty() && entry.soundSets.empty() && entry.groups.empty() && - !entry.unknownAfter) + !entry.unknownAfter && !entry.talkTextsAfter) { return; } @@ -138,7 +138,9 @@ bool MonsterEditHistory::IsAsLeft(const MonsterEditEntry& entry, bool undoing) c { return m_hooks.readSoundSet(step.name) == (undoing ? step.after : step.before); }); const bool unknownAsLeft = !entry.unknownAfter || !m_hooks.readUnknownLook || m_hooks.readUnknownLook() == *(undoing ? entry.unknownAfter : entry.unknownBefore); - return monstersAsLeft && modelsAsLeft && setsAsLeft && unknownAsLeft && + const bool talkTextsAsLeft = !entry.talkTextsAfter || !m_hooks.readTalkTexts || + m_hooks.readTalkTexts() == *(undoing ? entry.talkTextsAfter : entry.talkTextsBefore); + return monstersAsLeft && modelsAsLeft && setsAsLeft && unknownAsLeft && talkTextsAsLeft && std::ranges::all_of(entry.groups, [&](const MonsterGroupEditStep& step) { return m_hooks.readGroup(step.id) == (undoing ? step.after : step.before); }); } @@ -150,6 +152,10 @@ void MonsterEditHistory::Apply(const MonsterEditEntry& entry, bool undoing) { m_hooks.restoreUnknownLook(*(undoing ? entry.unknownBefore : entry.unknownAfter)); } + if (entry.talkTextsAfter && m_hooks.restoreTalkTexts) + { + m_hooks.restoreTalkTexts(*(undoing ? entry.talkTextsBefore : entry.talkTextsAfter)); + } if (!entry.steps.empty()) { std::vector snapshots; diff --git a/src/MuEditor/UI/MonsterTools/MonsterEditHistory.h b/src/MuEditor/UI/MonsterTools/MonsterEditHistory.h index 19cd146e7a..ae5676c1ac 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterEditHistory.h +++ b/src/MuEditor/UI/MonsterTools/MonsterEditHistory.h @@ -76,6 +76,10 @@ struct MonsterEditEntry // entry changed it. std::optional unknownBefore; std::optional unknownAfter; + // The shared talk texts before and after; both set when the entry + // changed them. + std::optional talkTextsBefore; + std::optional talkTextsAfter; // Made on a monster with several numbers as a whole (its shared values): // its undo selects the monster as a whole again. bool wholeMonster = false; @@ -110,6 +114,9 @@ class MonsterEditHistory // that change it. std::function readUnknownLook; std::function restoreUnknownLook; + // The same for the shared talk texts. + std::function readTalkTexts; + std::function restoreTalkTexts; }; // Why the last Undo or Redo did nothing. diff --git a/src/MuEditor/UI/MonsterTools/MonsterEdits.cpp b/src/MuEditor/UI/MonsterTools/MonsterEdits.cpp index 9318c524f5..0851c3cff1 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterEdits.cpp +++ b/src/MuEditor/UI/MonsterTools/MonsterEdits.cpp @@ -151,13 +151,30 @@ MonsterEdits::MonsterEdits() [](std::span snapshots) { g_MonsterDataHandler.RestoreModels(snapshots); }, [](const std::string& name) { return g_MonsterDataHandler.ReadSoundSet(name); }, [](std::span snapshots) - { g_MonsterDataHandler.RestoreSoundSets(snapshots); }, [](int id) - { return g_MonsterDataHandler.ReadGroup(id); }, [](std::span snapshots) - { g_MonsterDataHandler.RestoreGroups(snapshots); }, [] { return g_MonsterDatabase.GetUnknownLook(); }, - [](const Data::Monsters::UnknownMonsterLook& look) { g_MonsterDatabase.SetUnknownLook(look); }}) + { g_MonsterDataHandler.RestoreSoundSets(snapshots); }, + [](int id) { return g_MonsterDataHandler.ReadGroup(id); }, + [](std::span snapshots) { g_MonsterDataHandler.RestoreGroups(snapshots); }, + [] { return g_MonsterDatabase.GetUnknownLook(); }, [](const Data::Monsters::UnknownMonsterLook& look) + { g_MonsterDatabase.SetUnknownLook(look); }, [] { return g_MonsterDatabase.GetTalkTexts(); }, + [](const Data::Monsters::MonsterTalkTexts& texts) { g_MonsterDatabase.SetTalkTexts(texts); }}) { } +bool MonsterEdits::SetTalkTexts(std::string label, const Data::Monsters::MonsterTalkTexts& texts) +{ + const Data::Monsters::MonsterTalkTexts before = g_MonsterDatabase.GetTalkTexts(); + if (before == texts) + { + return false; + } + g_MonsterDatabase.SetTalkTexts(texts); + MonsterEditEntry entry{std::move(label), {}, {}, {}, {}}; + entry.talkTextsBefore = before; + entry.talkTextsAfter = texts; + m_history.Record(std::move(entry)); + return true; +} + bool MonsterEdits::SetUnknownLook(std::string label, const Data::Monsters::UnknownMonsterLook& look) { const Data::Monsters::UnknownMonsterLook before = g_MonsterDatabase.GetUnknownLook(); diff --git a/src/MuEditor/UI/MonsterTools/MonsterEdits.h b/src/MuEditor/UI/MonsterTools/MonsterEdits.h index 38dd24fecf..aae370d723 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterEdits.h +++ b/src/MuEditor/UI/MonsterTools/MonsterEdits.h @@ -34,6 +34,9 @@ class MonsterEdits // when it is so already. Living monsters with the look show its glow, // marks and message at once, its body at their next spawn. bool SetUnknownLook(std::string label, const Data::Monsters::UnknownMonsterLook& look); + // The talk texts several NPCs share (the list's talkTexts), saved with + // the monster list; false when they are so already. + bool SetTalkTexts(std::string label, const Data::Monsters::MonsterTalkTexts& texts); // Sets the monster's name in `locale`; an empty text removes the // translation. False, with nothing changed, when the number has no entry, // the locale is not a language code, the text has the separator diff --git a/src/MuEditor/UI/MonsterTools/MonsterSetupFields.cpp b/src/MuEditor/UI/MonsterTools/MonsterSetupFields.cpp index a8af742846..0ab20f7a7c 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterSetupFields.cpp +++ b/src/MuEditor/UI/MonsterTools/MonsterSetupFields.cpp @@ -64,6 +64,12 @@ constexpr std::array Fields{ Field{"gate", &I18N::Editor::CastleGate, GateAndHooks, NextSpawn}, Field{"hooks", &I18N::Editor::Hooks, GateAndHooks, NextSpawn}, Field{"stateVariants", &I18N::Editor::StateVariants, GateAndHooks, AtOnce}, + // The talk rules are read when the hero talks to the NPC. + Field{"talks", &I18N::Editor::Talks, NpcRules, AtOnce}, + Field{"talkLevel", &I18N::Editor::TalkLevel, NpcRules, AtOnce}, + Field{"repairShop", &I18N::Editor::RepairShop, NpcRules, AtOnce}, + Field{"onTalk", &I18N::Editor::OnTalk, NpcRules, AtOnce}, + Field{"crywolfContract", &I18N::Editor::CrywolfHeroContract, NpcRules, AtOnce}, }; } // namespace diff --git a/src/MuEditor/UI/MonsterTools/MonsterSetupFields.h b/src/MuEditor/UI/MonsterTools/MonsterSetupFields.h index 3240594d6d..54c3abc3ae 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterSetupFields.h +++ b/src/MuEditor/UI/MonsterTools/MonsterSetupFields.h @@ -21,6 +21,7 @@ enum class Section Appear, Behaviour, GateAndHooks, + NpcRules, }; // When a living monster of the number takes a change (D55): at once (its diff --git a/src/MuEditor/UI/MonsterTools/MonsterSetupLists.cpp b/src/MuEditor/UI/MonsterTools/MonsterSetupLists.cpp index 470ac868c6..744b0175b9 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterSetupLists.cpp +++ b/src/MuEditor/UI/MonsterTools/MonsterSetupLists.cpp @@ -4,10 +4,14 @@ #include "MonsterSetupLists.h" +#include "TalkStepStates.h" + #include "../MuEditor/UI/ItemTools/ItemFieldWidgets.h" #include "../MuEditor/UI/ItemTools/ItemTextInput.h" #include "Data/GameData/EffectData/EffectTypeSymbols.h" +#include "Data/GameData/ItemData/ItemJsonFormat.h" #include "Data/GameData/MonsterData/MonsterAnimations.h" +#include "Data/GameData/MonsterData/MonsterDatabase.h" #include "Data/GameData/MonsterData/MonsterSoundIds.h" #include "Data/GameData/MonsterData/MonsterModelDatabase.h" #include "I18N/All.h" @@ -16,8 +20,10 @@ #include #include #include +#include #include #include +#include #include namespace MuEditor::Monsters::SetupLists @@ -669,6 +675,319 @@ bool FacingByTile(std::vector& tiles, const A } return changed; } +namespace +{ +using Data::Monsters::MonsterTalkBlock; +using TalkKind = MonsterTalkBlock::Kind; + +struct TalkKindEntry +{ + TalkKind kind; + const char* const* name; +}; + +const std::array TalkKindNames{{ + {TalkKind::Dialog, &I18N::Editor::TalkDialog}, + {TalkKind::SendTalk, &I18N::Editor::SendTheTalkRequest}, + {TalkKind::SendPetInfo, &I18N::Editor::AskForPetInformation}, + {TalkKind::Message, &I18N::Editor::Message}, + {TalkKind::QuestWindow, &I18N::Editor::QuestWindowWithoutItsTitle}, + {TalkKind::CrywolfAltarContract, &I18N::Editor::CrywolfAltarContract}, +}}; + +const char* TalkKindName(TalkKind kind) +{ + const auto found = std::ranges::find(TalkKindNames, kind, &TalkKindEntry::kind); + return found != TalkKindNames.end() ? *found->name : ""; +} + +// The English text; the other languages are edited in Monster Translations. +void EnglishText(const char* label, Data::LocalizedString& text, float numbers, const AfterField& afterField) +{ + std::string english = text.GetNeutral(); + ImGui::SetNextItemWidth(Width(numbers)); + if (Items::InputString(label, english)) + text.Set(Data::LocalizedString::NeutralLocale, english); + afterField(); +} + +// A combo of the names; true when another was picked. +bool NameCombo(const char* label, std::string& value, std::span names, bool allowNone) +{ + bool changed = false; + ImGui::SetNextItemWidth(Width(1.6f)); + if (ImGui::BeginCombo(label, value.empty() ? I18N::Editor::None : value.c_str())) + { + if (allowNone && ImGui::Selectable(I18N::Editor::None, value.empty()) && !value.empty()) + { + value.clear(); + changed = true; + } + for (const std::string_view name : names) + { + if (ImGui::Selectable(std::string(name).c_str(), name == value) && name != value) + { + value = std::string(name); + changed = true; + } + } + ImGui::EndCombo(); + } + return changed; +} + +bool TalkOk(Data::Monsters::MonsterTalkOk& ok, const AfterField& afterField) +{ + bool changed = NameCombo(I18N::Editor::Request, ok.request, Data::Monsters::GetTalkRequests(), false); + if (NameCombo(I18N::Editor::IfTheQuest, ok.quest, Data::Monsters::GetTalkQuests(), true)) + { + // Without a quest there are no states to keep. + if (ok.quest.empty()) + ok.questStates.clear(); + changed = true; + } + if (!ok.quest.empty()) + { + ImGui::TextUnformatted(I18N::Editor::IsIn); + for (const std::string_view state : Data::Monsters::GetTalkQuestStates()) + { + ImGui::SameLine(); + const auto found = std::ranges::find(ok.questStates, state); + bool ticked = found != ok.questStates.end(); + if (ImGui::Checkbox(std::string(state).c_str(), &ticked)) + { + if (ticked) + ok.questStates.emplace_back(state); + else + ok.questStates.erase(found); + changed = true; + } + } + if (ok.questStates.empty()) + ImGui::TextColored(WarningColor, "%s", I18N::Editor::TickAStateWithoutOneTheOKSendsNothing); + } + int zen = static_cast(ok.zen); + ImGui::SetNextItemWidth(Width(1.2f)); + if (ImGui::InputInt(I18N::Editor::ZenItCosts, &zen, 0)) + ok.zen = static_cast(std::max(zen, 0)); + afterField(); + EnglishText(I18N::Editor::WithoutTheZen, ok.failure, 4.0f, afterField); + return changed; +} + +bool TalkDialog(Data::Monsters::MonsterTalkDialog& dialog, const AfterField& afterField) +{ + bool changed = false; + EnglishText(I18N::Editor::DialogTitle, dialog.title, 3.0f, afterField); + for (size_t index = 0; index < dialog.lines.size(); ++index) + { + ImGui::PushID(static_cast(index)); + EnglishText("##Line", dialog.lines[index], 5.0f, afterField); + ImGui::SameLine(); + const bool remove = ImGui::SmallButton("x"); + ImGui::PopID(); + if (remove) + { + dialog.lines.erase(dialog.lines.begin() + static_cast(index)); + return true; + } + } + if (ImGui::SmallButton(I18N::Editor::AddALine)) + { + dialog.lines.emplace_back(); + changed = true; + } + bool hasOk = dialog.ok.has_value(); + if (ImGui::Checkbox(I18N::Editor::OKSendsARequest, &hasOk)) + { + dialog.ok = hasOk ? std::optional( + Data::Monsters::MonsterTalkOk{std::string(Data::Monsters::GetTalkRequests().front())}) + : std::nullopt; + changed = true; + } + if (dialog.ok) + { + ImGui::Indent(); + changed |= TalkOk(*dialog.ok, afterField); + ImGui::Unindent(); + } + return changed; +} + +bool TalkBlockFields(MonsterTalkBlock& block, const AfterField& afterField) +{ + bool changed = false; + ImGui::SetNextItemWidth(Width(2.5f)); + if (ImGui::BeginCombo(I18N::Editor::Kind, TalkKindName(block.kind))) + { + for (const TalkKindEntry& kind : TalkKindNames) + { + if (ImGui::Selectable(*kind.name, kind.kind == block.kind) && kind.kind != block.kind) + { + // A step of the new kind, with none of the old one's values; + // an altar contract shows the first shared set. + block = MonsterTalkBlock{}; + block.kind = kind.kind; + if (block.kind == TalkKind::CrywolfAltarContract && !g_MonsterDatabase.GetTalkTexts().empty()) + block.altarTexts = g_MonsterDatabase.GetTalkTexts().begin()->first; + changed = true; + } + } + ImGui::EndCombo(); + } + switch (block.kind) + { + case TalkKind::Dialog: + changed |= TalkDialog(block.dialog, afterField); + break; + case TalkKind::Message: + EnglishText("##Message", block.message, 5.0f, afterField); + break; + case TalkKind::QuestWindow: + { + const std::span classes = Data::Items::GetClassKeys(); + for (size_t index = 0; index < classes.size(); ++index) + { + const int classIndex = static_cast(index); + const auto found = std::ranges::find(block.noQuestTitleFor, classIndex); + bool ticked = found != block.noQuestTitleFor.end(); + if (index > 0) + ImGui::SameLine(); + if (ImGui::Checkbox(classes[index], &ticked)) + { + if (ticked) + block.noQuestTitleFor.push_back(classIndex); + else + block.noQuestTitleFor.erase(found); + changed = true; + } + } + break; + } + case TalkKind::CrywolfAltarContract: + { + // The map's code makes the contract; its texts are a shared set. + std::vector names; + for (const auto& [name, contract] : g_MonsterDatabase.GetTalkTexts()) + names.emplace_back(name); + changed |= NameCombo(I18N::Editor::TextsOfTheSet, block.altarTexts, names, false); + ImGui::TextDisabled("%s", I18N::Editor::TheTextsAreEditedInSharedTalkTexts); + break; + } + case TalkKind::SendTalk: + case TalkKind::SendPetInfo: + break; + } + return changed; +} +// What tells two steps apart: the kind, the English texts and the values. +// Two equal steps may share it; they are interchangeable. +unsigned int StepSignature(const MonsterTalkBlock& block) +{ + std::string text = std::to_string(static_cast(block.kind)); + const auto add = [&](std::string_view part) + { + text += '\x1f'; + text += part; + }; + add(block.message.GetNeutral()); + add(block.dialog.title.GetNeutral()); + for (const Data::LocalizedString& line : block.dialog.lines) + add(line.GetNeutral()); + if (block.dialog.ok) + { + add(block.dialog.ok->request); + add(block.dialog.ok->quest); + for (const std::string& state : block.dialog.ok->questStates) + add(state); + add(std::to_string(block.dialog.ok->zen)); + add(block.dialog.ok->failure.GetNeutral()); + } + for (const int classIndex : block.noQuestTitleFor) + add(std::to_string(classIndex)); + add(block.altarTexts); + return static_cast(std::hash{}(text)); +} + +} // namespace + +bool TalkBlocks(std::vector& blocks, const AfterField& afterField) +{ + ImGui::TextDisabled("%s", I18N::Editor::TalkTextsNote); + // Each step's tree node is keyed by an id of its own, which follows it + // when steps move, go or come here, and is found again by its signature + // after a change made elsewhere (undo, redo). + TalkStepStates states(blocks.size()); + std::vector signatures; + for (const MonsterTalkBlock& block : blocks) + signatures.push_back(StepSignature(block)); + const std::vector ids = MatchStepIds(states.Before(), signatures, [&] { return states.NewId(); }); + for (size_t row = 0; row < ids.size(); ++row) + states.SetId(row, ids[row]); + const auto done = [&](bool changed) + { + if (changed) + { + signatures.clear(); + for (const MonsterTalkBlock& block : blocks) + signatures.push_back(StepSignature(block)); + } + states.Leave(signatures); + return changed; + }; + + for (size_t index = 0; index < blocks.size(); ++index) + { + ImGui::PushID(states.Id(index)); + const std::string title = std::to_string(index + 1) + ". " + TalkKindName(blocks[index].kind); + const bool open = ImGui::TreeNode("##Step", "%s", title.c_str()); + ImGui::SameLine(); + std::optional moveTo; + if (index > 0 && ImGui::SmallButton(I18N::Editor::MoveUp)) + moveTo = index - 1; + ImGui::SameLine(); + if (index + 1 < blocks.size() && ImGui::SmallButton(I18N::Editor::MoveDown)) + moveTo = index + 1; + ImGui::SameLine(); + const bool remove = ImGui::SmallButton("x"); + bool changed = false; + if (open) + { + if (!moveTo && !remove) + changed = TalkBlockFields(blocks[index], afterField); + ImGui::TreePop(); + } + ImGui::PopID(); + if (moveTo) + { + // The two steps change rows with their ids. + std::swap(blocks[index], blocks[*moveTo]); + const int idHere = states.Id(index); + states.SetId(index, states.Id(*moveTo)); + states.SetId(*moveTo, idHere); + return done(true); + } + if (remove) + { + // The later steps move up a row with their ids. + for (size_t row = index; row + 1 < blocks.size(); ++row) + states.SetId(row, states.Id(row + 1)); + blocks.erase(blocks.begin() + static_cast(index)); + return done(true); + } + if (changed) + return done(true); + } + if (ImGui::SmallButton(I18N::Editor::AddAStep)) + { + // A new step has a new id, so it starts closed. + states.SetId(blocks.size(), states.NewId()); + blocks.emplace_back(); + return done(true); + } + return done(false); +} + } // namespace MuEditor::Monsters::SetupLists #endif // _EDITOR diff --git a/src/MuEditor/UI/MonsterTools/MonsterSetupLists.h b/src/MuEditor/UI/MonsterTools/MonsterSetupLists.h index adb8c81f74..ebed126add 100644 --- a/src/MuEditor/UI/MonsterTools/MonsterSetupLists.h +++ b/src/MuEditor/UI/MonsterTools/MonsterSetupLists.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -103,6 +104,11 @@ int& TileOnAxis(Data::Monsters::MonsterTileFacing& tile); // How it comes in (Q-MN38c): the animation, blend, start frame, fade in, // appear time, sound and the effects where it comes in. bool Appear(Data::Monsters::MonsterAppear& appear, const AfterField& afterField); +// An NPC's talk steps in order (D66): a row per step, opened to its values +// (a dialog's title, lines and OK, a message, the classes of a quest window), +// with up, down and remove; Add a step. Texts are edited in English. +bool TalkBlocks(std::vector& blocks, const AfterField& afterField); + // A castle gate's animations closed, opening and open, and what an open gate // coming into view plays. bool Gate(Data::Monsters::MonsterGate& gate); diff --git a/src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.cpp b/src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.cpp new file mode 100644 index 0000000000..0765b6c6e8 --- /dev/null +++ b/src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.cpp @@ -0,0 +1,271 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "MonsterTalkTextRows.h" + +#include "MonsterEdits.h" +#include "MonsterOwnValues.h" +#include "MonsterSelection.h" +#include "MonsterShownValues.h" + +#include "../MuEditor/Core/MuEditorCore.h" +#include "../MuEditor/UI/Common/MuEditorWindowIds.h" +#include "../MuEditor/UI/Console/MuEditorConsoleUI.h" +#include "Data/DataHandler/MonsterData/MonsterDataHandler.h" +#include "Data/GameData/MonsterData/MonsterDatabase.h" +#include "Data/GameData/MonsterData/MonsterSetup.h" +#include "I18N/All.h" +#include "imgui.h" + +#include +#include +#include +#include + +namespace MuEditor::Monsters +{ +namespace +{ +using ShownValues::WholeMonsterKey; + +int NumberOf(int key) +{ + return key >= WholeMonsterKey ? key - WholeMonsterKey : key; +} + +std::string Lower(std::string_view text) +{ + std::string lower(text); + std::ranges::transform(lower, lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return lower; +} + +using TalkTexts = std::vector>; + +// The texts of a key's talk with their fields, its map and state variants' +// included. +TalkTexts TextsOf(int key) +{ + TalkTexts texts; + const std::optional shown = ShownValues::Load(key); + if (!shown) + return texts; + Data::Monsters::ForEachMonsterTalkText(shown->monster, + [&](const Data::LocalizedString& text, const std::string& field) + { texts.emplace_back(field, text); }); + return texts; +} +} // namespace + +std::vector MonsterTalkTextRows::CollectRows() const +{ + std::vector rows; + const auto add = [&](int key, TalkTexts texts) + { + for (auto& [field, text] : texts) + rows.push_back({static_cast(rows.size()), key, std::move(field), std::move(text)}); + }; + TalkTexts shared; + Data::Monsters::ForEachSharedTalkText(g_MonsterDatabase.GetTalkTexts(), + [&](const Data::LocalizedString& text, const std::string& field) + { shared.emplace_back(field, text); }); + add(SharedKey, std::move(shared)); + for (const std::vector& numbers : g_MonsterEdits.ReadMonsters()) + { + if (numbers.size() < 2) + { + add(numbers.front(), TextsOf(numbers.front())); + continue; + } + // The monster's texts, then those of a number whose talk is its own. + const int whole = numbers.front() + WholeMonsterKey; + const TalkTexts shared = TextsOf(whole); + add(whole, shared); + for (const int member : numbers) + { + TalkTexts own = TextsOf(member); + if (own != shared) + add(member, std::move(own)); + } + } + return rows; +} + +void MonsterTalkTextRows::Build() const +{ + if (m_builtVersion == GetVersion()) + return; + m_all = CollectRows(); + m_builtVersion = GetVersion(); +} + +const std::vector& MonsterTalkTextRows::List(std::string_view search, std::string_view missingLocale) +{ + Build(); + if (m_listed && m_listedVersion == m_builtVersion && search == m_search && missingLocale == m_missingLocale) + return m_rows; + m_search.assign(search); + m_missingLocale.assign(missingLocale); + m_rows.clear(); + const std::string needle = Lower(search); + for (const Row& row : m_all) + { + if (!m_missingLocale.empty() && row.text.HasOwn(m_missingLocale)) + continue; + char id[96]; + FormatId(row.id, id, sizeof(id)); + if (!needle.empty() && Lower(id).find(needle) == std::string::npos && + Lower(row.text.GetNeutral()).find(needle) == std::string::npos && + (row.key == SharedKey || + Lower(ShownValues::MonsterName(NumberOf(row.key))).find(needle) == std::string::npos)) + continue; + m_rows.push_back(row.id); + } + m_listed = true; + m_listedVersion = m_builtVersion; + return m_rows; +} + +void MonsterTalkTextRows::Refresh() +{ + m_listed = false; +} + +int MonsterTalkTextRows::GetVersion() const +{ + return g_MonsterDatabase.GetVersion() + g_MonsterDataHandler.GetGroupsVersion() + OwnValues::GetUnlockVersion(); +} + +std::vector MonsterTalkTextRows::CountMissing(std::span locales) const +{ + Build(); + std::vector missing(locales.size(), 0); + for (const Row& row : m_all) + { + for (size_t index = 0; index < locales.size(); ++index) + missing[index] += row.text.HasOwn(locales[index]) ? 0 : 1; + } + return missing; +} + +const Data::LocalizedString* MonsterTalkTextRows::FindNames(int id) const +{ + Build(); + return id >= 0 && id < static_cast(m_all.size()) ? &m_all[static_cast(id)].text : nullptr; +} + +void MonsterTalkTextRows::FormatId(int id, char* buffer, std::size_t size) const +{ + Build(); + if (id < 0 || id >= static_cast(m_all.size())) + { + std::snprintf(buffer, size, "?"); + return; + } + // "408 onTalk[1].dialog.lines[0]"; a monster with several numbers by its + // first one and how many it has. + const Row& row = m_all[static_cast(id)]; + if (row.key == SharedKey) + std::snprintf(buffer, size, "%s", row.field.c_str()); + else if (row.key >= WholeMonsterKey) + std::snprintf(buffer, size, "%d (%d) %s", NumberOf(row.key), + g_MonsterEdits.CountMonsterNumbers(NumberOf(row.key)), row.field.c_str()); + else + std::snprintf(buffer, size, "%d %s", row.key, row.field.c_str()); +} + +void MonsterTalkTextRows::CommitName(int id, const char* locale, const char* text) +{ + Build(); + if (id < 0 || id >= static_cast(m_all.size())) + return; + const Row row = m_all[static_cast(id)]; + if (row.key == SharedKey) + { + Data::Monsters::MonsterTalkTexts texts = g_MonsterDatabase.GetTalkTexts(); + bool changed = false; + Data::Monsters::ForEachSharedTalkText(texts, + [&](Data::LocalizedString& talkText, const std::string& field) + { + if (field == row.field && !changed) + { + talkText.Set(locale, text); + changed = true; + } + }); + const std::string label = I18N::Format(I18N::Editor::StepTalkText, {I18N::Editor::SharedTalkTexts, row.field, + I18N::GetLanguageDisplayName(locale)}); + if (changed) + g_MonsterEdits.SetTalkTexts(label, texts); + return; + } + std::optional shown = ShownValues::Load(row.key); + if (!shown) + return; + bool found = false; + Data::Monsters::ForEachMonsterTalkText(shown->monster, + [&](Data::LocalizedString& talkText, const std::string& field) + { + if (field == row.field && !found) + { + talkText.Set(locale, text); + found = true; + } + }); + if (!found) + return; + const std::string label = I18N::Format( + I18N::Editor::StepTalkText, {ShownValues::LogName(*shown), row.field, I18N::GetLanguageDisplayName(locale)}); + if (!ShownValues::Store(label, *shown)) + g_MuEditorConsoleUI.LogEditor(I18N::Editor::TheTalkTextWasNotStored); +} + +int MonsterTalkTextRows::GetSelected() const +{ + Build(); + // The first text of the selected monster's key. + const int key = ShownValues::GetSelectedKey(); + if (key < 0) + return -1; + const auto found = std::ranges::find(m_all, key, &Row::key); + return found != m_all.end() ? found->id : -1; +} + +int MonsterTalkTextRows::GetSelectionSerial() const +{ + return g_MonsterSelection.GetSerial(); +} + +void MonsterTalkTextRows::Select(int id) +{ + Build(); + if (id < 0 || id >= static_cast(m_all.size())) + return; + const int key = m_all[static_cast(id)].key; + if (key == SharedKey) + return; + if (key >= WholeMonsterKey) + g_MonsterSelection.SelectMonster(NumberOf(key)); + else + g_MonsterSelection.Select(key); +} + +void MonsterTalkTextRows::ShowBrowser() +{ + g_MuEditorCore.GetToolWindows().monsterBrowser = true; + ImGui::SetWindowFocus(WindowIds::MonsterBrowser); +} + +const char* MonsterTalkTextRows::GetSelectTooltip() const +{ + return I18N::Editor::SelectTheMonsterAndShowTheMonsterBrowser; +} + +const char* MonsterTalkTextRows::GetAllRowsLabel() const +{ + return I18N::Editor::AllTalkTexts; +} +} // namespace MuEditor::Monsters + +#endif // _EDITOR diff --git a/src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.h b/src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.h new file mode 100644 index 0000000000..291a0e2ab9 --- /dev/null +++ b/src/MuEditor/UI/MonsterTools/MonsterTalkTextRows.h @@ -0,0 +1,62 @@ +#pragma once + +#ifdef _EDITOR + +#include "../MuEditor/UI/Common/NamesGridRows.h" +#include "Data/GameData/Common/LocalizedString.h" + +#include +#include +#include + +// Monster Translations' talk texts (MN5, Q-MN47): a row per text of an NPC's +// talk (its level message, its dialogs' titles, lines and failure messages, +// its messages), by the monster's key (ShownValues) and the text's place, after +// a row per text of the shared talk texts (SharedKey). +namespace MuEditor::Monsters +{ +class MonsterTalkTextRows final : public NamesGridRows +{ +public: + const std::vector& List(std::string_view search, std::string_view missingLocale) override; + void Refresh() override; + int GetVersion() const override; + std::vector CountMissing(std::span locales) const override; + const Data::LocalizedString* FindNames(int id) const override; + void FormatId(int id, char* buffer, std::size_t size) const override; + void CommitName(int id, const char* locale, const char* text) override; + int GetSelected() const override; + int GetSelectionSerial() const override; + void Select(int id) override; + void ShowBrowser() override; + const char* GetSelectTooltip() const override; + const char* GetAllRowsLabel() const override; + +private: + // The key of the shared talk texts' rows, which belong to no monster. + static constexpr int SharedKey = -1; + + struct Row + { + int id = 0; + // The monster's key (ShownValues) and the text's field. + int key = 0; + std::string field; + Data::LocalizedString text; + }; + + // Every text of every monster's talk, by key in the monsters' order. + std::vector CollectRows() const; + void Build() const; + + mutable std::vector m_all; + mutable int m_builtVersion = -1; + std::vector m_rows; + std::string m_search; + std::string m_missingLocale; + bool m_listed = false; + int m_listedVersion = -1; +}; +} // namespace MuEditor::Monsters + +#endif // _EDITOR diff --git a/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.cpp b/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.cpp index 934d2ee1da..225f4e2f7e 100644 --- a/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.cpp +++ b/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include @@ -94,6 +95,10 @@ MonsterSetup MakeStartValues() setup.appear = Data::Monsters::MonsterAppear{}; setup.teleport = Data::Monsters::MonsterAppear{}; setup.gate = Data::Monsters::MonsterGate{}; + setup.talks = true; + setup.talkLevel = Data::Monsters::MonsterTalkLevel{1, {}}; + setup.repairShop = true; + setup.crywolfContract = true; return setup; } @@ -114,15 +119,15 @@ bool IsSummaryField(std::string_view key) // appear, teleport and the gate once set. bool IsBlockField(std::string_view key) { - return key == "appear" || key == "teleport" || key == "gate"; + return key == "appear" || key == "teleport" || key == "gate" || key == "talkLevel"; } // The fields that are lists: on the monster always edited (a row added or // removed); on a variant edited when it has the list or the monster has none. bool IsListField(std::string_view key) { - constexpr std::array Lists{"spawnEffects", "spawnBuffs", "randomLook", "facingByTile", - "hooks"}; + constexpr std::array Lists{"spawnEffects", "spawnBuffs", "randomLook", + "facingByTile", "hooks", "onTalk"}; return std::ranges::find(Lists, key) != Lists.end(); } @@ -250,6 +255,16 @@ std::string Describe(std::string_view key, const MonsterSetup& setup) return optionalNumber(setup.deathEffect); if (key == "hooks") return RowCount(setup.hooks.size()); + if (key == "talks") + return YesNo(*setup.talks); + if (key == "talkLevel") + return std::to_string(setup.talkLevel->level); + if (key == "repairShop") + return YesNo(*setup.repairShop); + if (key == "crywolfContract") + return YesNo(*setup.crywolfContract); + if (key == "onTalk") + return RowCount(setup.onTalk.size()); return std::string(); } @@ -278,6 +293,8 @@ const char* SectionName(Section section) return I18N::Editor::Behaviour; case Section::GateAndHooks: return I18N::Editor::GateAndHooks; + case Section::NpcRules: + return I18N::Editor::NPCRules; } return ""; } @@ -357,6 +374,7 @@ void MuMonsterSetupUI::Render(bool* open) } m_draft.End(); RenderUnknownMonsters(); + RenderSharedTalkTexts(); } ToolWindow::End(); } @@ -510,6 +528,111 @@ void MuMonsterSetupUI::RenderUnknownMonsters() ImGui::PopID(); } +namespace +{ +// The shared sets the monsters' talk steps name (their entries, map and +// state variants), found again only when the monsters change. +const std::set& NamedTalkTextSets() +{ + static std::set named; + static int builtVersion = -1; + if (builtVersion == g_MonsterDatabase.GetVersion()) + return named; + named.clear(); + const auto add = [&](const Data::Monsters::MonsterSetup& setup) + { + for (const Data::Monsters::MonsterTalkBlock& block : setup.onTalk) + { + if (block.kind == Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract) + named.insert(block.altarTexts); + } + }; + const auto withStates = [&](const Data::Monsters::MonsterSetup& setup) + { + add(setup); + for (const Data::Monsters::MonsterStateVariant& state : setup.stateVariants) + add(state.setup); + }; + for (int number = 0; number < Data::Monsters::MonsterNumberCount; ++number) + { + const Data::Monsters::MonsterDefinition* monster = g_MonsterDatabase.Find(number); + if (monster == nullptr) + continue; + withStates(monster->setup); + for (const Data::Monsters::MonsterMapVariant& variant : monster->mapVariants) + withStates(variant.setup); + } + builtVersion = g_MonsterDatabase.GetVersion(); + return named; +} +} // namespace + +void MuMonsterSetupUI::RenderSharedTalkTexts() +{ + if (!ImGui::CollapsingHeader(I18N::Editor::SharedTalkTexts)) + return; + ImGui::PushID("SharedTalkTexts"); + ImGui::TextDisabled("%s", I18N::Editor::SharedTalkTextsNote); + if (!m_talkTextsEditing) + m_talkTexts = g_MonsterDatabase.GetTalkTexts(); + bool store = false; + bool active = false; + // The altar contract's texts, in MonsterAltarContract's order. + const std::array labels{I18N::Editor::ContractsLeft, I18N::Editor::TheQuestion, + I18N::Editor::OnAMount, I18N::Editor::OnCancel, + I18N::Editor::AltarDestroyed, I18N::Editor::ContractOngoing}; + std::optional removed; + for (auto& [name, contract] : m_talkTexts) + { + ImGui::PushID(name.c_str()); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(name.c_str()); + ImGui::SameLine(); + // A set a step names stays, so no NPC is left without its texts. + const bool named = NamedTalkTextSets().contains(name); + ImGui::BeginDisabled(named); + if (ImGui::SmallButton("x")) + removed = name; + ImGui::EndDisabled(); + if (named && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", I18N::Editor::ATalkStepNamesThisSet); + const auto texts = Data::Monsters::AltarContractTexts(contract); + for (size_t index = 0; index < texts.size(); ++index) + { + std::string english = texts[index]->GetNeutral(); + ImGui::SetNextItemWidth(Items::Fields::ScaledWidth(Items::Fields::NumberWidth * 5.0f)); + if (Items::InputString(labels[index], english)) + texts[index]->Set(Data::LocalizedString::NeutralLocale, english); + active |= ImGui::IsItemActive(); + store |= ImGui::IsItemDeactivatedAfterEdit(); + } + ImGui::PopID(); + } + if (removed) + { + m_talkTexts.erase(*removed); + store = true; + } + ImGui::SetNextItemWidth(Items::Fields::ScaledWidth(Items::Fields::NumberWidth * 2.0f)); + Items::InputString(I18N::Editor::SetName, m_newSetName); + ImGui::SameLine(); + ImGui::BeginDisabled(m_newSetName.empty() || m_talkTexts.contains(m_newSetName)); + if (ImGui::SmallButton(I18N::Editor::AddASet)) + { + m_talkTexts.emplace(m_newSetName, Data::Monsters::MonsterAltarContract{}); + m_newSetName.clear(); + store = true; + } + ImGui::EndDisabled(); + m_talkTextsEditing = active && !store; + if (store) + { + g_MonsterEdits.SetTalkTexts(I18N::Editor::SharedTalkTexts, m_talkTexts); + m_talkTextsEditing = false; + } + ImGui::PopID(); +} + std::string MuMonsterSetupUI::SetupLabel() const { return m_shown != nullptr ? ShownValues::LogName(*m_shown) + ": " + I18N::Editor::MonsterSetup : std::string(); @@ -568,7 +691,7 @@ void MuMonsterSetupUI::RenderSetup(MonsterDefinition& monster) RenderVariantLine(monster); ImGui::Separator(); for (const Section section : - {Section::Look, Section::Spawn, Section::Appear, Section::Behaviour, Section::GateAndHooks}) + {Section::Look, Section::Spawn, Section::Appear, Section::Behaviour, Section::GateAndHooks, Section::NpcRules}) { RenderSection(section, monster); } @@ -1108,6 +1231,26 @@ bool MuMonsterSetupUI::RenderValue(std::string_view key, MonsterSetup& setup, co return boolField(setup.live); if (key == "deathEffect") return intField(setup.deathEffect); + if (key == "talks") + return boolField(setup.talks); + if (key == "repairShop") + return boolField(setup.repairShop); + if (key == "crywolfContract") + return boolField(setup.crywolfContract); + if (key == "talkLevel" && setup.talkLevel) + { + ImGui::SetNextItemWidth(width); + if (ImGui::InputInt(I18N::Editor::Level, &setup.talkLevel->level, 0)) + setup.talkLevel->level = std::clamp(setup.talkLevel->level, 0, Data::Monsters::MaxTalkLevel); + m_draft.AfterField(undoLabel); + // The English text; the other languages in Monster Translations. + std::string message = setup.talkLevel->message.GetNeutral(); + ImGui::SetNextItemWidth(width * 4.0f); + if (Items::InputString(I18N::Editor::Message, message)) + setup.talkLevel->message.Set(Data::LocalizedString::NeutralLocale, message); + m_draft.AfterField(undoLabel); + return false; + } const auto afterField = [&] { m_draft.AfterField(undoLabel); }; if (key == "spawnEffects") return SetupLists::SpawnEffects(setup.spawnEffects, afterField, SetupLists::EffectRows::Spawn); @@ -1125,6 +1268,8 @@ bool MuMonsterSetupUI::RenderValue(std::string_view key, MonsterSetup& setup, co return SetupLists::Gate(*setup.gate); if (key == "hooks") return SetupLists::Hooks(setup.hooks); + if (key == "onTalk") + return SetupLists::TalkBlocks(setup.onTalk, afterField); return false; } } // namespace MuEditor::Monsters diff --git a/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.h b/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.h index 0558a9b0b5..2c84cd6a4b 100644 --- a/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.h +++ b/src/MuEditor/UI/MonsterTools/MuMonsterSetupUI.h @@ -79,6 +79,9 @@ class MuMonsterSetupUI // The unknown monsters' look (D64), for every monster: its body, size, // glow, marks, message and who gets it, and Preview as a number. void RenderUnknownMonsters(); + // The talk texts several NPCs share (the list's talkTexts): each set's + // texts in English, Add a set and x. + void RenderSharedTalkTexts(); std::string SetupLabel() const; MonsterDraft m_draft; @@ -121,6 +124,11 @@ class MuMonsterSetupUI // The look while a field of it is edited, and the number Preview as shows. Data::Monsters::UnknownMonsterLook m_unknown; bool m_unknownEditing = false; + // The shared talk texts while a field of them is edited, and the name a + // new set gets. + Data::Monsters::MonsterTalkTexts m_talkTexts; + bool m_talkTextsEditing = false; + std::string m_newSetName; int m_previewNumber = 700; }; } // namespace MuEditor::Monsters diff --git a/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.cpp b/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.cpp index e8c78d3356..77b514eb69 100644 --- a/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.cpp +++ b/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.cpp @@ -27,6 +27,7 @@ namespace { // Keeps the table's column widths and hidden columns. constexpr char TableId[] = "##MonsterTranslations"; +constexpr char TalkTableId[] = "##MonsterTalkTexts"; // Pixel width before the editor UI scale. constexpr float TooltipWrapWidth = 420.0f; @@ -129,18 +130,36 @@ MuMonsterTranslationsUI& MuMonsterTranslationsUI::GetInstance() void MuMonsterTranslationsUI::Render(bool* open) { m_grid.BeginFrame(); + m_talkGrid.BeginFrame(); // One of the monster tools: their docking, undo keys and selection. if (ToolWindow::Begin(I18N::Editor::MonsterTranslations, WindowIds::MonsterTranslations, open)) { - m_grid.UpdateCountsAndLabels(m_rows); RenderFileActions(); - m_grid.RenderToolbar(m_rows); - ImGui::Separator(); - m_grid.RenderTable(m_rows, TableId); + if (ImGui::BeginTabBar("##Texts")) + { + if (ImGui::BeginTabItem(I18N::Editor::Names)) + { + m_grid.UpdateCountsAndLabels(m_rows); + m_grid.RenderToolbar(m_rows); + ImGui::Separator(); + m_grid.RenderTable(m_rows, TableId); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem(I18N::Editor::TalkTexts)) + { + m_talkGrid.UpdateCountsAndLabels(m_talkRows); + m_talkGrid.RenderToolbar(m_talkRows); + ImGui::Separator(); + m_talkGrid.RenderTable(m_talkRows, TalkTableId); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } } ToolWindow::End(); m_grid.CommitEditNotDrawn(m_rows); + m_talkGrid.CommitEditNotDrawn(m_talkRows); } void MuMonsterTranslationsUI::RenderFileActions() diff --git a/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.h b/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.h index e31506efb6..7377507b09 100644 --- a/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.h +++ b/src/MuEditor/UI/MonsterTools/MuMonsterTranslationsUI.h @@ -3,6 +3,7 @@ #ifdef _EDITOR #include "MonsterNameRows.h" +#include "MonsterTalkTextRows.h" #include "../MuEditor/UI/Common/NamesGrid.h" @@ -13,7 +14,8 @@ namespace MuEditor::Monsters // Monster Translations: the monster names as a grid of monsters and the // languages of the UI (English first), with how many names each language // misses and a filter for them (the names grid), and the import and export of -// the original client's NpcName txt files. Edits go into the monster table +// the original client's NpcName txt files; in a tab of their own the texts +// of the NPCs' talk (MN5, Q-MN47). Edits go into the monster table // right away and reach the monsters in the game; Save Monsters writes them. class MuMonsterTranslationsUI { @@ -31,6 +33,8 @@ class MuMonsterTranslationsUI NamesGrid m_grid; MonsterNameRows m_rows; + NamesGrid m_talkGrid; + MonsterTalkTextRows m_talkRows; // What the last import or export did, in short; the console has the rest. std::string m_status; }; diff --git a/src/MuEditor/UI/MonsterTools/TalkStepStates.cpp b/src/MuEditor/UI/MonsterTools/TalkStepStates.cpp new file mode 100644 index 0000000000..de84116f6c --- /dev/null +++ b/src/MuEditor/UI/MonsterTools/TalkStepStates.cpp @@ -0,0 +1,101 @@ +#include "stdafx.h" + +#ifdef _EDITOR + +#include "TalkStepStates.h" + +#include + +namespace MuEditor::Monsters::SetupLists +{ +std::vector MatchStepIds(std::span before, std::span signatures, + const std::function& newId) +{ + std::vector taken(before.size(), false); + std::vector ids(signatures.size(), 0); + const auto take = [&](size_t row, size_t old) + { + ids[row] = before[old].id; + taken[old] = true; + }; + // The same step in the same row. + for (size_t row = 0; row < signatures.size() && row < before.size(); ++row) + { + if (before[row].id != 0 && before[row].signature == signatures[row]) + take(row, row); + } + // A step that moved. + for (size_t row = 0; row < signatures.size(); ++row) + { + for (size_t old = 0; old < before.size() && ids[row] == 0; ++old) + { + if (!taken[old] && before[old].id != 0 && before[old].signature == signatures[row]) + take(row, old); + } + } + // A step changed in its row, else a new one. + for (size_t row = 0; row < signatures.size(); ++row) + { + if (ids[row] == 0 && row < before.size() && !taken[row] && before[row].id != 0) + take(row, row); + if (ids[row] == 0) + ids[row] = newId(); + } + return ids; +} + +TalkStepStates::TalkStepStates(size_t rows) : m_storage(ImGui::GetStateStorage()) +{ + ImGui::PushID("##Steps"); + m_countKey = ImGui::GetID("count"); + m_nextKey = ImGui::GetID("next"); + const size_t keys = std::max(rows, KnownCount()) + 1; + for (size_t row = 0; row < keys; ++row) + { + ImGui::PushID(static_cast(row)); + m_idKeys.push_back(ImGui::GetID("id")); + m_signatureKeys.push_back(ImGui::GetID("signature")); + ImGui::PopID(); + } + ImGui::PopID(); +} + +std::vector TalkStepStates::Before() const +{ + std::vector rows; + for (size_t row = 0; row < KnownCount(); ++row) + rows.push_back({static_cast(m_storage->GetInt(m_signatureKeys[row], 0)), Id(row)}); + return rows; +} + +int TalkStepStates::NewId() +{ + const int id = m_storage->GetInt(m_nextKey, 1); + m_storage->SetInt(m_nextKey, id + 1); + return id; +} + +int TalkStepStates::Id(size_t row) const +{ + return m_storage->GetInt(m_idKeys[row], 0); +} + +void TalkStepStates::SetId(size_t row, int id) +{ + m_storage->SetInt(m_idKeys[row], id); +} + +void TalkStepStates::Leave(std::span signatures) +{ + m_storage->SetInt(m_countKey, static_cast(signatures.size())); + for (size_t row = 0; row < signatures.size(); ++row) + m_storage->SetInt(m_signatureKeys[row], static_cast(signatures[row])); +} + +size_t TalkStepStates::KnownCount() const +{ + return static_cast(std::max(m_storage->GetInt(m_countKey, 0), 0)); +} +} // namespace MuEditor::Monsters::SetupLists + +#endif // _EDITOR diff --git a/src/MuEditor/UI/MonsterTools/TalkStepStates.h b/src/MuEditor/UI/MonsterTools/TalkStepStates.h new file mode 100644 index 0000000000..5f867388a8 --- /dev/null +++ b/src/MuEditor/UI/MonsterTools/TalkStepStates.h @@ -0,0 +1,57 @@ +#pragma once + +#ifdef _EDITOR + +#include "imgui.h" + +#include +#include +#include +#include + +// The open steps of a talk step list in Monster Setup (MN5): each step's tree +// node is keyed by an id of its own, kept in the window's state by row, so it +// follows the step when steps move, go or come, and is found again by the +// step's signature after a change made elsewhere (undo, redo). +namespace MuEditor::Monsters::SetupLists +{ +// A row of a talk step list as it was left: its step's signature and id. +struct StepRow +{ + unsigned int signature = 0; + int id = 0; +}; + +// The id of each step now (by its signature) from the rows before: the same +// step in its row keeps it, a step that moved takes its id along, a step +// changed in its row keeps the row's, any other gets newId(). +std::vector MatchStepIds(std::span before, std::span signatures, + const std::function& newId); + +// The window's state of a step list: the steps' ids by row and the rows' +// signatures as the list left them, under the ID stack it is made in. +class TalkStepStates +{ +public: + explicit TalkStepStates(size_t rows); + + // The rows as the list left them on its last frame: signature and id. + std::vector Before() const; + int NewId(); + int Id(size_t row) const; + void SetId(size_t row, int id); + // The list as it is left this frame. + void Leave(std::span signatures); + +private: + size_t KnownCount() const; + + ImGuiStorage* m_storage; + ImGuiID m_countKey = 0; + ImGuiID m_nextKey = 0; + std::vector m_idKeys; + std::vector m_signatureKeys; +}; +} // namespace MuEditor::Monsters::SetupLists + +#endif // _EDITOR diff --git a/src/bin/Data/Monsters/Monsters.json b/src/bin/Data/Monsters/Monsters.json index c0db9c82cf..314b055a7e 100644 --- a/src/bin/Data/Monsters/Monsters.json +++ b/src/bin/Data/Monsters/Monsters.json @@ -8,6 +8,88 @@ "messageSeconds": 10, "for": ["noEntry", "noModel", "missingFile"] }, + "talkTexts": { + "crywolfAltar": { + "canContract": { + "en": "Contract can be made for %d times.", + "de": "Der Vertrag kann %d-mal geschlossen werden.", + "es": "El contrato se puede realizar por tiempos %d.", + "id": "Kontrak dapat dibuat untuk waktu %d.", + "ja": "契約可能な回数は%d回です。", + "pl": "Kontrakt można zawrzeć %d razy.", + "pt": "O contrato pode ser feito para horários %d.", + "ru": "Контракт может быть заключен на %d раз.", + "tl": "Maaaring gawin ang kontrata para sa %d beses.", + "uk": "Контракт можна укласти на %d разів.", + "zh-TW": "可以簽訂%d次的合約。" + }, + "proceed": { + "en": "Would you like to proceed with the contract?", + "de": "Möchten Sie den Vertrag schließen?", + "es": "¿Le gustaría continuar con el contrato?", + "id": "Apakah Anda ingin melanjutkan kontrak?", + "ja": "契約しますか?", + "pl": "Czy chcesz kontynuować realizację umowy?", + "pt": "Gostaria de prosseguir com o contrato?", + "ru": "Хотите продолжить контракт?", + "tl": "Gusto mo bang magpatuloy sa kontrata?", + "uk": "Бажаєте продовжити контракт?", + "zh-TW": "您想繼續履行合約嗎?" + }, + "onMount": { + "en": "Contract can't be made when you are on a mount.", + "de": "Auf einem Reittier kann kein Vertrag geschlossen werden.", + "es": "No se puede hacer contrato cuando estás en una montura.", + "id": "Kontrak tidak dapat dibuat saat Anda berada di atas tunggangan.", + "ja": "乗物に乗った状態では契約できません。", + "pl": "Kontraktu nie można zawrzeć będąc na wierzchowcu.", + "pt": "O contrato não pode ser feito quando você está montado.", + "ru": "Контракт не может быть заключен, когда вы находитесь на скакуне.", + "tl": "Ang kontrata ay hindi maaaring gawin kapag ikaw ay nasa bundok.", + "uk": "Контракт не можна укласти, коли ви перебуваєте на верховій їзді.", + "zh-TW": "坐騎時無法簽訂契約。" + }, + "needGuardian": { + "en": "We need a guardian to protect the wolf.", + "de": "Wir brauchen einen Wächter, der den Wolf beschützt.", + "es": "Necesitamos un guardián para proteger al lobo.", + "id": "Kita membutuhkan wali untuk melindungi serigala.", + "ja": "狼の神像を守る契約者が必要です。", + "pl": "Potrzebujemy strażnika, który będzie chronił wilka.", + "pt": "Precisamos de um guardião para proteger o lobo.", + "ru": "Нам нужен страж, который защитит волка.", + "tl": "Kailangan natin ng tagapag-alaga para protektahan ang lobo.", + "uk": "Нам потрібен охоронець, щоб захистити вовка.", + "zh-TW": "我們需要一個守護者來保護狼。" + }, + "destroyed": { + "en": "Further contract can't be done since the altar has been destroyed.", + "de": "Ein weiterer Vertrag kann nicht abgeschlossen werden, da der Altar zerstört wurde.", + "es": "No se pueden realizar más contratos ya que el altar ha sido destruido.", + "id": "Kontrak lebih lanjut tidak dapat dilakukan karena altar telah dihancurkan.", + "ja": "祭壇が破壊され、これ以上の契約ができません。", + "pl": "Dalszego kontraktu nie można wykonać, gdyż ołtarz został zniszczony.", + "pt": "Novos contratos não podem ser feitos porque o altar foi destruído.", + "ru": "Дальнейший контракт невозможно заключить, так как алтарь разрушен.", + "tl": "Ang karagdagang kontrata ay hindi maaaring gawin dahil ang altar ay nawasak.", + "uk": "Подальший контракт неможливий, оскільки вівтар було зруйновано.", + "zh-TW": "由於祭壇已被毀壞,進一步的契約無法進行。" + }, + "ongoing": { + "en": "Contract is ongoing therefore dual compact is not possible.", + "de": "Es läuft bereits ein Vertrag, daher ist ein zweiter Vertrag nicht möglich.", + "es": "El contrato está en curso, por lo que no es posible un pacto dual.", + "id": "Kontrak sedang berlangsung sehingga dual compact tidak dimungkinkan.", + "ja": "契約中であるため、重複契約はできません。", + "pl": "Kontrakt jest w toku, nie można zawrzeć drugiego.", + "pt": "O contrato está em andamento, portanto o pacto duplo não é possível.", + "ru": "Контракт действует, поэтому двойной договор невозможен.", + "tl": "Patuloy ang kontrata kaya hindi posible ang dual compact.", + "uk": "Контракт діє, тому подвійний компакт неможливий.", + "zh-TW": "合約正在進行中,因此雙重緊湊是不可能的。" + } + } + }, "monsters": [ { "name": {"en": "Bull Fighter", "es": "Toro Luchador", "pt": "Toureiro"}, @@ -2554,6 +2636,7 @@ "kind": "npc", "model": "MODEL_CRYWOLF_STATUE", "live": false, + "talks": false, "numbers": [ {"number": 204, "maps": [34]} ] @@ -2564,6 +2647,12 @@ "model": "MODEL_CRYWOLF_ALTAR1", "hiddenMesh": -2, "heightOffset": -10, + "onTalk": [ + { + "crywolfAltarContract": "crywolfAltar" + } + ], + "crywolfContract": true, "numbers": [ {"number": 205, "maps": [34]} ] @@ -2574,6 +2663,12 @@ "model": "MODEL_CRYWOLF_ALTAR2", "hiddenMesh": -2, "heightOffset": -10, + "onTalk": [ + { + "crywolfAltarContract": "crywolfAltar" + } + ], + "crywolfContract": true, "numbers": [ {"number": 206, "maps": [34]} ] @@ -2584,6 +2679,12 @@ "model": "MODEL_CRYWOLF_ALTAR3", "hiddenMesh": -2, "heightOffset": -10, + "onTalk": [ + { + "crywolfAltarContract": "crywolfAltar" + } + ], + "crywolfContract": true, "numbers": [ {"number": 207, "maps": [34]} ] @@ -2594,6 +2695,12 @@ "model": "MODEL_CRYWOLF_ALTAR4", "hiddenMesh": -2, "heightOffset": -10, + "onTalk": [ + { + "crywolfAltarContract": "crywolfAltar" + } + ], + "crywolfContract": true, "numbers": [ {"number": 208, "maps": [34]} ] @@ -2604,6 +2711,12 @@ "model": "MODEL_CRYWOLF_ALTAR5", "hiddenMesh": -2, "heightOffset": -10, + "onTalk": [ + { + "crywolfAltarContract": "crywolfAltar" + } + ], + "crywolfContract": true, "numbers": [ {"number": 209, "maps": [34]} ] @@ -2739,6 +2852,15 @@ "name": {"en": "Trainer", "es": "Adiestrador", "pt": "Treinador"}, "kind": "npc", "model": "MODEL_NPC_BREEDER", + "onTalk": [ + { + "send": "talk" + }, + { + "send": "petInfo" + } + ], + "crywolfContract": true, "numbers": [ {"number": 226, "maps": [0, 34]} ] @@ -2782,6 +2904,20 @@ "level": 7 } }, + "onTalk": [ + { + "send": "talk" + }, + { + "questWindow": { + "noQuestTitleFor": [ + "darkLord", + "magicGladiator", + "rageFighter" + ] + } + } + ], "numbers": [ {"number": 229, "maps": [0, 2, 3, 7]} ] @@ -2804,6 +2940,7 @@ "part": "MODEL_MERCHANT_MAN_BOOTS" } }, + "crywolfContract": true, "numbers": [ {"number": 230, "maps": [0]} ] @@ -2887,6 +3024,22 @@ "model": "MODEL_MIX_NPC", "soundSet": "Chaos Goblin", "glowMesh": 1, + "talkLevel": { + "level": 10, + "message": { + "en": "Only level above %d can do the Chaos Combination.", + "de": "Nur Charaktere über Level %d können die Chaos-Kombination durchführen.", + "es": "Sólo el nivel superior a %d puede realizar la Combinación del Caos.", + "id": "Hanya level di atas %d yang dapat melakukan Kombinasi Chaos.", + "ja": "レベル %d以上からカオス合成が可能です。", + "pl": "Kombinację Chaos mogą wykonać tylko postacie powyżej poziomu %d.", + "pt": "Somente níveis acima de %d podem fazer a Combinação do Caos.", + "ru": "Только уровень выше %d может выполнить Комбинацию Хаоса.", + "tl": "Ang antas lamang sa itaas ng %d ang makakagawa ng Chaos Combination.", + "uk": "Тільки рівень вище %d може виконувати комбінацію хаосу.", + "zh-TW": "只有%d以上的等級才能進行混沌組合。" + } + }, "numbers": [ {"number": 238, "maps": [3]} ] @@ -2903,6 +3056,7 @@ "name": {"en": "Safety Guardian", "es": "Guardián de Seguridad", "pt": "Guardião de segurança"}, "kind": "npc", "model": "MODEL_STORAGE", + "crywolfContract": true, "numbers": [ {"number": 240, "maps": [0, 2, 3, 4, 6, 7, 34, 51, 80]} ] @@ -2930,6 +3084,7 @@ "name": {"en": "Eo the Craftsman", "es": "Eo el Artesano", "pt": "Eo, o artesão"}, "kind": "npc", "model": "MODEL_ELF_MERCHANT", + "repairShop": true, "numbers": [ {"number": 243, "maps": [3]} ] @@ -2954,6 +3109,7 @@ "name": {"en": "Weapons Merchant Zienna", "es": "Mercader de Armas Zienna", "pt": "Zienna, a Mercadora de armas"}, "kind": "npc", "model": "MODEL_SNOW_SMITH", + "repairShop": true, "numbers": [ {"number": 246, "maps": [2]} ] @@ -3018,6 +3174,7 @@ "part": "MODEL_MERCHANT_MAN_BOOTS+1" } }, + "crywolfContract": true, "numbers": [ {"number": 248, "maps": [0, 34]} ] @@ -3040,6 +3197,7 @@ "part": "MODEL_MERCHANT_MAN_BOOTS" } }, + "crywolfContract": true, "numbers": [ {"number": 250, "maps": [0]} ] @@ -3050,6 +3208,8 @@ "model": "MODEL_SMITH", "soundSet": "Hanzo the Blacksmith", "scale": 0.95, + "repairShop": true, + "crywolfContract": true, "numbers": [ {"number": 251, "maps": [0, 34]} ] @@ -3114,6 +3274,7 @@ "name": {"en": "Lahap", "es": "Lahap", "pt": "Lahap"}, "kind": "npc", "model": "MODEL_NPC_SERBIS", + "crywolfContract": true, "numbers": [ {"number": 256, "maps": [2, 34, 51]} ] @@ -4617,6 +4778,116 @@ "kind": "npc", "model": "MODEL_NPC_QUARREL", "scale": 1.9, + "stateVariants": [ + { + "state": "crywolfElfTime", + "onTalk": [ + { + "send": "talk" + } + ] + } + ], + "onTalk": [ + { + "dialog": { + "title": { + "en": "Werewolf Guardsman", + "de": "Werwolf-Wächter", + "es": "Guardia hombre lobo", + "id": "Penjaga Manusia Serigala", + "ja": "ウェアウルフコーラル", + "pl": "Werewolf Guardsman", + "pt": "Guarda Lobisomem", + "ru": "Оборотень-гвардеец", + "tl": "Werewolf Guardsman", + "uk": "Гвардійський перевертень", + "zh-TW": "狼人守衛" + }, + "lines": [ + { + "en": "'Do you even know about me? I've been both blessed and cursed from Lugard. You may be helped if you are appropriately qualified.'", + "de": "„Wissen Sie überhaupt etwas über mich? Ich wurde von Lugard sowohl gesegnet als auch verflucht. Wenn Sie entsprechend qualifiziert sind, kann Ihnen geholfen werden.“", + "es": "'¿Sabes siquiera acerca de mí? He sido bendecido y maldecido por parte de Lugard. Es posible que le ayuden si está debidamente cualificado.", + "id": "'Apakah kamu tahu tentang aku? Saya telah diberkati sekaligus dikutuk dari Lugard. Anda mungkin terbantu jika Anda memiliki kualifikasi yang sesuai.'", + "ja": "「私を知っているのか?私はルガド様の祝福と呪いを同時に受けた者だ。君が資格を持っていれば者であれば助けを与えよう。」", + "pl": "Czy ty w ogóle o mnie wiesz? Zostałem pobłogosławiony i przeklęty przez Lugarda. Jeśli masz odpowiednie kwalifikacje, możesz uzyskać pomoc.", + "pt": "'Você ao menos sabe sobre mim? Fui abençoado e amaldiçoado por Lugard. Você pode ser ajudado se estiver devidamente qualificado.", + "ru": "— Ты вообще знаешь обо мне? Лугард одновременно благословил и проклял меня. Вам могут помочь, если вы обладаете соответствующей квалификацией».", + "tl": "'May alam ka pa ba tungkol sa akin? Pareho akong pinagpala at isinumpa mula kay Lugard. Maaari kang matulungan kung ikaw ay angkop na kuwalipikado.'", + "uk": "«Ти взагалі про мене знаєш? Мене одночасно благословив і прокляв Лугард. Вам можуть допомогти, якщо ви маєте належну кваліфікацію».", + "zh-TW": "'你還了解我嗎?我既受到盧加德的祝福,也受到盧加德的詛咒。如果您有適當的資格,您可能會得到幫助。" + }, + { + "en": "If you have passed through the Apostle Devin's test, Werewolf Guardsman will send you and your party members Balgass' Barrack.", + "de": "Wenn Sie die Prüfung des Apostels Devin bestanden haben, schickt der Werwolf-Wächter Sie und Ihre Partymitglieder in die Balgass-Kaserne.", + "es": "Si has pasado la prueba del Apóstol Devin, Werewolf Guardsman te enviará a ti y a los miembros de tu grupo el Cuartel de Balgass.", + "id": "Jika Anda telah lulus ujian Rasul Devin, Penjaga Manusia Serigala akan mengirim Anda dan anggota partai Anda Balgass' Barrack.", + "ja": "君が「司祭デヴィン」の試練を通過した者なら君と仲間たちを「バルガスの兵営」へ送ろう。", + "pl": "Jeśli przeszedłeś próbę Apostoła Devina, Werewolf Guardsman wyśle ciebie i członków twojej grupy do Balgass' Barrack.", + "pt": "Se você passou no teste do Apóstolo Devin, o Guarda Lobisomem enviará você e os membros do seu grupo Balgass' Barrack.", + "ru": "Если вы прошли испытание апостола Девина, гвардеец-оборотень отправит вас и членов вашей группы в казарму Балгасса.", + "tl": "Kung nakapasa ka sa pagsubok ni Apostol Devin, ipapadala ka ng Werewolf Guardsman at ng iyong mga miyembro ng partido na Balgass' Barrack.", + "uk": "Якщо ви пройшли випробування Апостола Девіна, Werewolf Guardsman надішле вам і членам вашої групи казарму Балгаса.", + "zh-TW": "如果你通過了使徒德文的考驗,狼人守衛將把你和你的小隊成員送到巴爾加斯的兵營。" + }, + { + "en": "You must be located closely together in order to enter Balgass' Barrack at once.", + "de": "Um gemeinsam die Balgass-Kaserne zu betreten, müssen Sie dicht beieinander stehen.", + "es": "Deben estar ubicados muy juntos para poder ingresar al Cuartel de Balgass de inmediato.", + "id": "Anda harus berada berdekatan agar dapat memasuki Barak Balgass sekaligus.", + "ja": "あなたのパーティーメンバーたちが「バルガスの兵営」に一緒に入場するためにはあなたの近くにいなければなりません。", + "pl": "Aby od razu wejść do Balgass' Barrack, musicie znajdować się blisko siebie.", + "pt": "Vocês devem estar próximos um do outro para entrar no Quartel de Balgass imediatamente.", + "ru": "Вы должны находиться близко друг к другу, чтобы сразу войти в казарму Балгасса.", + "tl": "Dapat kang matatagpuan malapit nang magkasama upang makapasok nang sabay-sabay sa Balgass' Barrack.", + "uk": "Ви повинні розташуватися близько один до одного, щоб відразу потрапити в казарму Балгаса.", + "zh-TW": "你們必須靠得很近才能立即進入巴爾加斯的軍營。" + }, + { + "en": "In order to receive help from Werewolf Guardsman; you must pay him 3,000,000 Zen.", + "de": "Um Hilfe vom Werwolf-Wächter zu erhalten, müssen Sie ihm 3.000.000 Zen zahlen.", + "es": "Para recibir ayuda de Werewolf Guardsman; debes pagarle 3.000.000 Zen.", + "id": "Untuk menerima bantuan dari Werewolf Guardsman; Anda harus membayarnya 3.000.000 Zen.", + "ja": "「ウェアウルフコーラル」に手伝ってもらうためには彼に300万ゼンを払わなければならない。", + "pl": "Aby otrzymać pomoc od Werewolf Guardsman, musisz mu zapłacić 3 000 000 Zen.", + "pt": "Para receber ajuda do Guarda Lobisomem; você deve pagar a ele 3.000.000 Zen.", + "ru": "Чтобы получить помощь от гвардейца-оборотня; вы должны заплатить ему 3 000 000 дзэн.", + "tl": "Upang makatanggap ng tulong mula sa Werewolf Guardsman; kailangan mong bayaran siya ng 3,000,000 Zen.", + "uk": "Щоб отримати допомогу від Werewolf Guardsman; ви повинні заплатити йому 3 000 000 дзен.", + "zh-TW": "為了得到狼人守衛的幫助;你必須付給他 3,000,000 Zen。" + } + ], + "ok": { + "request": "enterRefuge", + "if": { + "quest": "thirdChangeUp2", + "is": [ + "ongoing", + "done" + ] + }, + "zen": 3000000, + "failure": { + "en": "You are short of Zen.", + "de": "Ihnen fehlt Zen.", + "es": "Te falta Zen.", + "id": "Anda kekurangan Zen.", + "ja": "ゼンが不足しています。", + "pl": "Brakuje ci Zen.", + "pt": "Você está com falta de Zen.", + "ru": "Вам не хватает дзэн.", + "tl": "Kulang ka kay Zen.", + "uk": "Вам не вистачає Дзен.", + "zh-TW": "你缺乏禪宗。" + } + } + } + }, + { + "send": "talk" + } + ], "numbers": [ {"number": 407, "maps": [34]} ] @@ -4629,6 +4900,78 @@ "outline": 1.1, "shadow": false, "heightOffset": 240, + "onTalk": [ + { + "send": "talk" + }, + { + "dialog": { + "title": { + "en": "Gatekeeper", + "de": "Torwächter", + "es": "Portero", + "id": "Penjaga gerbang", + "ja": "門番", + "pl": "Odźwierny", + "pt": "Porteiro", + "ru": "привратник", + "tl": "Gatekeeper", + "uk": "Воротар", + "zh-TW": "看門人" + }, + "lines": [ + { + "en": "'Hmm, who are you? I'm confused. Are you even approved of Balgass?", + "de": "„Hmm, wer sind Sie? Ich bin verwirrt. Hat Balgass Sie überhaupt zugelassen?“", + "es": "'Mmm, ¿quién eres? Estoy confundido. ¿Apruebas siquiera a Balgass?", + "id": "'Hmm, siapa kamu? saya bingung. Apakah Anda menyetujui Balgass?", + "ja": "「誰だ??怪しい奴だな。お前はバルガス様の許可をもらった者なのか?」", + "pl": "'Hm, kim jesteś? Nic nie rozumiem. Czy Balgass w ogóle cię przyjął?", + "pt": "'Hum, quem é você? Estou confuso. Você ao menos aprovou Balgass?", + "ru": "'Хм, а ты кто? Я в замешательстве. Вы вообще одобряете Балгасса?", + "tl": "'Hmm, sino ka? naguguluhan ako. Approved ka na ba kay Balgass?", + "uk": "«Хм, хто ти? я збентежений Ви взагалі схвалюєте Балгасса?", + "zh-TW": "’嗯,你是誰?我很困惑。您是否認可巴爾加斯?" + }, + { + "en": "Lugadr's 12 apostles are helping by blinding the gatekeeper by the road to Balgass' Resting Place.", + "de": "Die 12 Apostel von Lugard helfen, indem sie den Torwächter am Weg zu Balgass’ Ruhestätte blenden.", + "es": "Los 12 apóstoles de Lugadr están ayudando cegando al portero en el camino hacia el lugar de descanso de Balgass.", + "id": "Ke-12 rasul Lugadr membantu dengan membutakan penjaga gerbang di jalan menuju Tempat Peristirahatan Balgass.", + "ja": "クエスト遂行中はルガドの12司祭の力で「安息所」の門番の目を欺くことが可能です。", + "pl": "Pomaga mu 12 apostołów Lugadra, oślepiając odźwiernego przy drodze do miejsca spoczynku Balgassa.", + "pt": "Os 12 apóstolos de Lugadr estão ajudando cegando o porteiro na estrada para o local de descanso de Balgass.", + "ru": "12 апостолов Лугадра помогают ослепить привратника на дороге к месту упокоения Балгасса.", + "tl": "Ang 12 apostol ni Lugadr ay tumutulong sa pamamagitan ng pagbulag sa bantay-pinto sa daan patungo sa Balgass' Resting Place.", + "uk": "12 апостолів Лугадра допомагають, засліплюючи воротаря біля дороги до місця спочинку Балгаса.", + "zh-TW": "盧加德爾的 12 名使徒正在幫忙,他們蒙蔽了通往巴爾加斯安息之地的道路旁的守門人。" + }, + { + "en": "Apostle Devin's third mission request enables entrance into the resting place.", + "de": "Die dritte Mission des Apostels Devin ermöglicht den Zutritt zur Ruhestätte.", + "es": "La tercera petición de misión del apóstol Devin permite la entrada al lugar de descanso.", + "id": "Permintaan misi ketiga Rasul Devin memungkinkan masuk ke tempat peristirahatan.", + "ja": "司祭デヴィンの三番目の要請を遂行する間、安息所への入場が可能になります。", + "pl": "Trzecia misja apostoła Devina umożliwia wejście do miejsca spoczynku.", + "pt": "O terceiro pedido missionário do apóstolo Devin permite a entrada no local de descanso.", + "ru": "Третья миссия апостола Девина позволяет войти в место упокоения.", + "tl": "Ang ikatlong kahilingan sa misyon ni Apostol Devin ay nagbibigay-daan sa pagpasok sa pahingahang lugar.", + "uk": "Третє прохання апостола Девіна про місію дозволяє ввійти до місця спочинку.", + "zh-TW": "使徒德文的第三個任務請求允許進入安息之地。" + } + ], + "ok": { + "request": "enterBarracks", + "if": { + "quest": "thirdChangeUp3", + "is": [ + "ongoing" + ] + } + } + } + } + ], "numbers": [ {"number": 408, "maps": [41]} ] @@ -4778,6 +5121,7 @@ "model": "MODEL_ELBELAND_RHEA", "scale": 1, "outline": 1.1, + "repairShop": true, "numbers": [ {"number": 416, "maps": [51]} ] @@ -5285,6 +5629,26 @@ "model": "MODEL_LITTLESANTA+2", "scale": 0.43, "facing": 90, + "onTalk": [ + { + "send": "talk" + }, + { + "message": { + "en": "Health has been recovered of 100%.", + "de": "Das Leben wurde zu 100% wiederhergestellt.", + "es": "La salud se ha recuperado del 100%.", + "id": "Kesehatan telah pulih 100%.", + "ja": "体力が100%回復しました。", + "pl": "Zdrowie zostało odzyskane w 100%.", + "pt": "A saúde foi recuperada de 100%.", + "ru": "Здоровье восстановлено на 100%.", + "tl": "Ang kalusugan ay nakuhang muli ng 100%.", + "uk": "Здоров'я відновлено на 100%.", + "zh-TW": "健康已恢復100%。" + } + } + ], "numbers": [ {"number": 470, "maps": [62]} ] @@ -5295,6 +5659,26 @@ "model": "MODEL_LITTLESANTA+3", "scale": 0.43, "facing": 90, + "onTalk": [ + { + "send": "talk" + }, + { + "message": { + "en": "Mana has been recovered of 100%.", + "de": "Das Mana wurde zu 100% wiederhergestellt.", + "es": "Mana se ha recuperado del 100%.", + "id": "Mana telah pulih 100%.", + "ja": "マナが100%回復しました。", + "pl": "Mana została odzyskana w 100%.", + "pt": "Mana foi recuperada em 100%.", + "ru": "Мана восстановлена ​​на 100%.", + "tl": "Nabawi ang Mana ng 100%.", + "uk": "Мана була відновлена ​​на 100%.", + "zh-TW": "魔力已恢復100%。" + } + } + ], "numbers": [ {"number": 471, "maps": [62]} ] @@ -5363,6 +5747,7 @@ "name": {"en": "Delgado", "es": "Delgado", "pt": "Delgado"}, "kind": "npc", "model": "MODEL_NPC_SERBIS", + "crywolfContract": true, "numbers": [ {"number": 478, "maps": [2]} ] @@ -6817,6 +7202,7 @@ "name": {"en": "Weapons Merchant Bolo", "es": "Weapons Merchant Bolo", "pt": "Weapons Merchant Bolo"}, "kind": "npc", "model": "MODEL_KARUTAN_NPC_VOLVO", + "repairShop": true, "numbers": [ {"number": 578, "maps": [80]} ] diff --git a/src/source/Data/DataHandler/MonsterData/MonsterDataHandler.cpp b/src/source/Data/DataHandler/MonsterData/MonsterDataHandler.cpp index 0839e71373..5fc43ca182 100644 --- a/src/source/Data/DataHandler/MonsterData/MonsterDataHandler.cpp +++ b/src/source/Data/DataHandler/MonsterData/MonsterDataHandler.cpp @@ -134,6 +134,7 @@ bool MonsterDataHandler::Load(std::string& errorMessage) g_MonsterDatabase.Build(result.monsters, I18N::GetCurrentLocale()); g_MonsterDatabase.SetUnknownLook(result.unknown); + g_MonsterDatabase.SetTalkTexts(result.talkTexts); #ifdef _EDITOR SetGroups(result.groups); #endif @@ -299,7 +300,8 @@ bool MonsterDataHandler::Save(std::vector& issues) { ValidateLocales(CollectMonsters(), issues); std::vector groups = CollectGroups(); - return SaveMonstersFile(GetMonstersFile(), groups, g_MonsterDatabase.GetUnknownLook(), issues); + return SaveMonstersFile(GetMonstersFile(), groups, g_MonsterDatabase.GetUnknownLook(), + g_MonsterDatabase.GetTalkTexts(), issues); } bool MonsterDataHandler::Revert(std::vector& issues) @@ -312,6 +314,7 @@ bool MonsterDataHandler::Revert(std::vector& issues) return false; } g_MonsterDatabase.SetUnknownLook(result.unknown); + g_MonsterDatabase.SetTalkTexts(result.talkTexts); UseMonsters(result.groups); return true; } @@ -516,8 +519,8 @@ bool MonsterDataHandler::SaveAll(std::vector& issues) ValidateLocales(CollectMonsters(), issues); std::vector groups = CollectGroups(); // All are checked, so that the problems of all are listed. - const std::optional monstersText = - MakeMonstersFileText(GetMonstersFile(), groups, g_MonsterDatabase.GetUnknownLook(), issues); + const std::optional monstersText = MakeMonstersFileText( + GetMonstersFile(), groups, g_MonsterDatabase.GetUnknownLook(), g_MonsterDatabase.GetTalkTexts(), issues); const std::optional modelsText = MakeMonsterModelsFileText(GetMonsterModelsFile(), CollectModels(), issues); const std::vector sets = CollectSoundSets(); @@ -586,7 +589,7 @@ MonsterDataHandler::MakeMonstersFileWith(const std::function& issues) @@ -603,6 +606,7 @@ bool MonsterDataHandler::RevertAll(std::vector& issues) return false; } g_MonsterDatabase.SetUnknownLook(monsters.unknown); + g_MonsterDatabase.SetTalkTexts(monsters.talkTexts); UseMonsters(monsters.groups); UseModels(models.models); UseSoundSets(sounds.sets); diff --git a/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.cpp b/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.cpp index 465236dca1..347dd9afdd 100644 --- a/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.cpp +++ b/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.cpp @@ -39,7 +39,7 @@ MonsterDataLoadResult LoadMonstersFile(const std::filesystem::path& file) AddFatalFileIssue(result.issues, source, "could not be read"); return result; } - ReadMonstersJson(*text, source, result.groups, result.unknown, result.issues); + ReadMonstersJson(*text, source, result.groups, result.unknown, result.talkTexts, result.issues); result.monsters = CollectNumberEntries(result.groups); const std::size_t firstValidationIssue = result.issues.size(); ValidateMonsterNames(result.monsters, result.issues); @@ -48,9 +48,10 @@ MonsterDataLoadResult LoadMonstersFile(const std::filesystem::path& file) } bool SaveMonstersFile(const std::filesystem::path& file, std::span monsters, - const UnknownMonsterLook& unknown, std::vector& issues) + const UnknownMonsterLook& unknown, const MonsterTalkTexts& talkTexts, + std::vector& issues) { - const std::optional text = MakeMonstersFileText(file, monsters, unknown, issues); + const std::optional text = MakeMonstersFileText(file, monsters, unknown, talkTexts, issues); if (!text) { return false; @@ -66,10 +67,11 @@ bool SaveMonstersFile(const std::filesystem::path& file, std::span MakeMonstersFileText(const std::filesystem::path& file, std::span monsters, - const UnknownMonsterLook& unknown, std::vector& issues) + const UnknownMonsterLook& unknown, const MonsterTalkTexts& talkTexts, + std::vector& issues) { const std::string source = file.generic_string(); - std::string text = WriteMonstersJson(monsters, unknown); + std::string text = WriteMonstersJson(monsters, unknown, talkTexts); std::vector readBack; ReadMonstersJson(text, source, readBack, issues); const std::size_t firstValidationIssue = issues.size(); diff --git a/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.h b/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.h index 12c60971d0..a781ea4922 100644 --- a/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.h +++ b/src/source/Data/DataHandler/MonsterData/MonsterJsonStorage.h @@ -27,6 +27,8 @@ struct MonsterDataLoadResult std::vector monsters; // How the game shows a monster it cannot show as itself (D64). UnknownMonsterLook unknown; + // The talk texts several NPCs share, by name. + MonsterTalkTexts talkTexts; std::vector issues; }; @@ -37,7 +39,8 @@ MonsterDataLoadResult LoadMonstersFile(const std::filesystem::path& file); // problems found are added to `issues`. The old file stays when the new one // cannot be written whole. bool SaveMonstersFile(const std::filesystem::path& file, std::span monsters, - const UnknownMonsterLook& unknown, std::vector& issues); + const UnknownMonsterLook& unknown, const MonsterTalkTexts& talkTexts, + std::vector& issues); // The text SaveMonstersFile writes, so that a caller can check more files // before it writes any (with WriteTextFiles): the text for the file when the @@ -45,7 +48,7 @@ bool SaveMonstersFile(const std::filesystem::path& file, std::span MakeMonstersFileText(const std::filesystem::path& file, std::span monsters, - const UnknownMonsterLook& unknown, + const UnknownMonsterLook& unknown, const MonsterTalkTexts& talkTexts, std::vector& issues); // Makes `file` the source of the problems from `first` on: the checks of the diff --git a/src/source/Data/GameData/MonsterData/MonsterDataValidation.cpp b/src/source/Data/GameData/MonsterData/MonsterDataValidation.cpp index 4a19076adf..f03643e49f 100644 --- a/src/source/Data/GameData/MonsterData/MonsterDataValidation.cpp +++ b/src/source/Data/GameData/MonsterData/MonsterDataValidation.cpp @@ -5,6 +5,7 @@ #include "Core/Text/Utf8.h" #include +#include #include namespace Data::Monsters @@ -21,6 +22,16 @@ void AddIssue(std::vector& issues, ItemDataIssueSeverity severity "monster " + std::to_string(monster.number) + " name", message}); } +// The models of the five Crywolf altars, which the altar contract's code +// counts from (CrywolfAltarTalk). +bool IsCrywolfAltarModel(const std::string& model) +{ + static constexpr std::array Altars{"MODEL_CRYWOLF_ALTAR1", "MODEL_CRYWOLF_ALTAR2", + "MODEL_CRYWOLF_ALTAR3", "MODEL_CRYWOLF_ALTAR4", + "MODEL_CRYWOLF_ALTAR5"}; + return std::ranges::find(Altars, model) != Altars.end(); +} + bool IsTooLongForGame(const std::string& text) { std::string cut = text; @@ -81,7 +92,10 @@ void ValidateMonsterSetups(std::span monsters, c { for (const MonsterDefinition* monster : monsters) { - const auto check = [&](const MonsterSetup& setup, const std::string& where) + // `where` names the place for live as it always did, `talkWhere` for + // the talk steps as the other talk checks do ("mapVariants[0]."). + const auto check = [&](const MonsterSetup& setup, const std::string& model, const std::string& where, + const std::string& talkWhere) { if (setup.live == false) { @@ -90,16 +104,97 @@ void ValidateMonsterSetups(std::span monsters, c "is false: the monster is dropped when it appears, so it is not drawn, cannot be " "clicked, and later packets for it find nothing, as with the old code (P24)"}); } + const bool altarStep = + std::ranges::any_of(setup.onTalk, [](const MonsterTalkBlock& block) + { return block.kind == MonsterTalkBlock::Kind::CrywolfAltarContract; }); + if (altarStep && !IsCrywolfAltarModel(model)) + { + issues.push_back( + {ItemDataIssueSeverity::Warning, source, ItemDataIssue::NoItem, monster->number, + "monster " + std::to_string(monster->number) + " " + talkWhere + "onTalk", + "has a crywolfAltarContract step but its model " + model + " is no Crywolf altar: it only talks"}); + } + }; + const auto checkStates = [&](const MonsterSetup& setup, const std::string& model, const std::string& where, + const std::string& talkWhere) + { + check(setup, model, where, talkWhere); + for (size_t index = 0; index < setup.stateVariants.size(); ++index) + check(setup.stateVariants[index].setup, model, + where + " (state " + setup.stateVariants[index].state + ")", + talkWhere + "stateVariants[" + std::to_string(index) + "]."); + }; + checkStates(monster->setup, monster->model, "", ""); + for (size_t index = 0; index < monster->mapVariants.size(); ++index) + { + const MonsterMapVariant& variant = monster->mapVariants[index]; + checkStates(variant.setup, variant.model.value_or(monster->model), + " (map variant " + std::to_string(index + 1) + ")", + "mapVariants[" + std::to_string(index) + "]."); + } + } +} + +void ValidateMonsterTalkSteps(std::span monsters, const MonsterTalkTexts& talkTexts, + const std::string& source, std::vector& issues) +{ + for (const MonsterDefinition* monster : monsters) + { + const std::string name = "monster " + std::to_string(monster->number) + " "; + const auto check = [&](const MonsterSetup& setup, const std::string& where) + { + for (size_t index = 0; index < setup.onTalk.size(); ++index) + { + const MonsterTalkBlock& block = setup.onTalk[index]; + const std::string step = name + where + "onTalk[" + std::to_string(index) + "]"; + if (block.kind == MonsterTalkBlock::Kind::Dialog && block.dialog.ok && + !block.dialog.ok->quest.empty() && block.dialog.ok->questStates.empty()) + issues.push_back({ItemDataIssueSeverity::Warning, source, ItemDataIssue::NoItem, monster->number, + step + ".dialog.ok.if.is", "is empty: the OK sends nothing"}); + if (block.kind == MonsterTalkBlock::Kind::CrywolfAltarContract && !talkTexts.contains(block.altarTexts)) + issues.push_back({ItemDataIssueSeverity::Error, source, ItemDataIssue::NoItem, monster->number, + step + ".crywolfAltarContract", + "names " + (block.altarTexts.empty() ? std::string("no set") : block.altarTexts) + + ", which the shared talk texts do not have: the altar shows empty texts"}); + } }; const auto checkStates = [&](const MonsterSetup& setup, const std::string& where) { check(setup, where); - for (const MonsterStateVariant& state : setup.stateVariants) - check(state.setup, where + " (state " + state.state + ")"); + for (size_t index = 0; index < setup.stateVariants.size(); ++index) + check(setup.stateVariants[index].setup, where + "stateVariants[" + std::to_string(index) + "]."); }; checkStates(monster->setup, ""); for (size_t index = 0; index < monster->mapVariants.size(); ++index) - checkStates(monster->mapVariants[index].setup, " (map variant " + std::to_string(index + 1) + ")"); + checkStates(monster->mapVariants[index].setup, "mapVariants[" + std::to_string(index) + "]."); + } +} + +void ValidateMonsterTalkTexts(std::span monsters, const MonsterTalkTexts& talkTexts, + std::span uiLocales, const std::string& source, + std::vector& issues) +{ + const auto checkText = [&](const LocalizedString& text, int number, const std::string& field) + { + std::string missing; + for (const char* locale : uiLocales) + { + if (!text.HasOwn(locale)) + missing += (missing.empty() ? "" : ", ") + std::string(locale); + } + if (!missing.empty()) + issues.push_back({ItemDataIssueSeverity::Warning, source, ItemDataIssue::NoItem, number, field, + "has no text in " + missing + ": the English one shows there"}); + }; + ForEachSharedTalkText(talkTexts, [&](const LocalizedString& text, const std::string& field) + { checkText(text, ItemDataIssue::NoItem, field); }); + // The fields as Monster Translations' Talk texts names them + // ("mapVariants[0].onTalk[0].message"), so its search finds them. + for (const MonsterDefinition* monster : monsters) + { + ForEachMonsterTalkText( + *monster, [&](const LocalizedString& text, const std::string& field) + { checkText(text, monster->number, "monster " + std::to_string(monster->number) + " " + field); }); } } diff --git a/src/source/Data/GameData/MonsterData/MonsterDataValidation.h b/src/source/Data/GameData/MonsterData/MonsterDataValidation.h index 75cbac9c3e..bd630d7a13 100644 --- a/src/source/Data/GameData/MonsterData/MonsterDataValidation.h +++ b/src/source/Data/GameData/MonsterData/MonsterDataValidation.h @@ -22,7 +22,22 @@ void ValidateMonsterNameLocales(std::span monsters, std // Old behaviours the setup keeps (Q-MN36), a warning each: `live` false drops // the character when it appears (the Wolf Status, P24), on the entry, a map -// variant or a state variant. +// variant or a state variant; a crywolfAltarContract step on a model that is +// no Crywolf altar only talks. void ValidateMonsterSetups(std::span monsters, const std::string& source, std::vector& issues); +// The talk steps as the editor leaves them, on the entry and its map and state +// variants ("monster 408 mapVariants[0].onTalk[1]..."): a dialog OK whose +// quest has no state never sends (a warning), and an altar contract step +// naming a set the shared talk texts do not have shows empty texts and stops +// Save (an error). +void ValidateMonsterTalkSteps(std::span monsters, const MonsterTalkTexts& talkTexts, + const std::string& source, std::vector& issues); +// A text of an NPC's talk without a text of its own in one of the game's +// languages (`uiLocales`) shows its English one there (Q-MN47): a warning per +// text naming the languages, on the entry, a map variant, a state variant or +// the shared talk texts, by the fields Monster Translations shows. +void ValidateMonsterTalkTexts(std::span monsters, const MonsterTalkTexts& talkTexts, + std::span uiLocales, const std::string& source, + std::vector& issues); } // namespace Data::Monsters diff --git a/src/source/Data/GameData/MonsterData/MonsterDatabase.h b/src/source/Data/GameData/MonsterData/MonsterDatabase.h index 09d6e158b9..d7796f5a21 100644 --- a/src/source/Data/GameData/MonsterData/MonsterDatabase.h +++ b/src/source/Data/GameData/MonsterData/MonsterDatabase.h @@ -35,6 +35,18 @@ class MonsterDatabase return m_unknownLook; } + // The talk texts several NPCs share, by name (the list's talkTexts); a + // change counts as one of the monsters (GetVersion). + void SetTalkTexts(const MonsterTalkTexts& texts) + { + m_talkTexts = texts; + ++m_version; + } + const MonsterTalkTexts& GetTalkTexts() const + { + return m_talkTexts; + } + // Shows the names in another language: a missing translation shows English. void SetDisplayLocale(std::string_view locale); const std::string& GetDisplayLocale() const @@ -93,6 +105,7 @@ class MonsterDatabase std::array m_englishNames{}; std::string m_displayLocale = std::string(LocalizedString::NeutralLocale); UnknownMonsterLook m_unknownLook; + MonsterTalkTexts m_talkTexts; int m_version = 0; int m_builds = 0; }; diff --git a/src/source/Data/GameData/MonsterData/MonsterDefinition.h b/src/source/Data/GameData/MonsterData/MonsterDefinition.h index acc41b1c27..a12f89bb4f 100644 --- a/src/source/Data/GameData/MonsterData/MonsterDefinition.h +++ b/src/source/Data/GameData/MonsterData/MonsterDefinition.h @@ -110,4 +110,25 @@ const std::string& GetSoundSetWithModel(const MonsterDefinition& monster, int sl // the monster names too (Data::Maps::Overlaps); a map in two variants is // fatal (Q-MN65). std::vector FindMapsOfOtherVariants(const MonsterDefinition& monster, size_t variant); + +// Each text of the monster's talk with its field (ForEachTalkText), on the +// entry, its state variants ("stateVariants[0].onTalk[0].message"), its map +// variants ("mapVariants[1].talkLevel.message") and theirs. `monster` may be +// const. +template void ForEachMonsterTalkText(TMonster& monster, TVisit&& visit) +{ + const auto withStates = [&](auto& setup, const std::string& prefix) + { + ForEachTalkText(setup, [&](auto& text, const std::string& field) { visit(text, prefix + field); }); + for (size_t index = 0; index < setup.stateVariants.size(); ++index) + { + const std::string state = prefix + "stateVariants[" + std::to_string(index) + "]."; + ForEachTalkText(setup.stateVariants[index].setup, + [&](auto& text, const std::string& field) { visit(text, state + field); }); + } + }; + withStates(monster.setup, ""); + for (size_t index = 0; index < monster.mapVariants.size(); ++index) + withStates(monster.mapVariants[index].setup, "mapVariants[" + std::to_string(index) + "]."); +} } // namespace Data::Monsters diff --git a/src/source/Data/GameData/MonsterData/MonsterSetup.cpp b/src/source/Data/GameData/MonsterData/MonsterSetup.cpp index 974e5fb7b2..d9760bfdd4 100644 --- a/src/source/Data/GameData/MonsterData/MonsterSetup.cpp +++ b/src/source/Data/GameData/MonsterData/MonsterSetup.cpp @@ -98,6 +98,11 @@ const MonsterSetupField SetupFields[] = { MONSTER_SETUP_OPTIONAL(gate), MONSTER_SETUP_LIST(stateVariants), MONSTER_SETUP_LIST(hooks), + MONSTER_SETUP_OPTIONAL(talks), + MONSTER_SETUP_OPTIONAL(talkLevel), + MONSTER_SETUP_OPTIONAL(repairShop), + MONSTER_SETUP_LIST(onTalk), + MONSTER_SETUP_OPTIONAL(crywolfContract), }; #undef MONSTER_SETUP_OPTIONAL @@ -150,9 +155,57 @@ constexpr MonsterHookKind HookKinds[] = { // playerTransformed: the monster is made for a player transformed into it // (0x45, Q-MN38d); crywolfReady: Crywolf's battle is about to start, its -// monsters come in at once (Q-MN38c). +// monsters come in at once (Q-MN38c); crywolfElfTime: on Crywolf only elves +// may make the altar contracts (the NPCs' talk, MN5). constexpr std::string_view States[] = {"castleSiegeRunning", "castleSiegeNotRunning", "raklionBossWaiting", - "playerTransformed", "crywolfReady"}; + "playerTransformed", "crywolfReady", "crywolfElfTime"}; + +// In the order of the game's requests and quest indexes (QUEST_CHANGE_UP_1 +// ...) and states (QUEST_NONE ...). +constexpr std::string_view TalkRequests[] = {"enterRefuge", "enterBarracks"}; +constexpr std::string_view TalkQuests[] = {"changeUp1", "changeUp2", "changeUp3", "combo", + "thirdChangeUp1", "thirdChangeUp2", "thirdChangeUp3"}; +constexpr std::string_view TalkQuestStates[] = {"none", "ongoing", "done", "no", "ready", "error"}; + +std::span GetTalkRequests() +{ + return TalkRequests; +} + +std::span GetTalkQuests() +{ + return TalkQuests; +} + +std::span GetTalkQuestStates() +{ + return TalkQuestStates; +} + +std::span GetAltarContractTextKeys() +{ + static constexpr std::array Keys{"canContract", "proceed", "onMount", + "needGuardian", "destroyed", "ongoing"}; + return Keys; +} + +std::array AltarContractTexts(MonsterAltarContract& contract) +{ + return {&contract.canContract, &contract.proceed, &contract.onMount, + &contract.needGuardian, &contract.destroyed, &contract.ongoing}; +} + +std::array AltarContractTexts(const MonsterAltarContract& contract) +{ + return {&contract.canContract, &contract.proceed, &contract.onMount, + &contract.needGuardian, &contract.destroyed, &contract.ongoing}; +} + +std::optional FindTalkName(std::span names, std::string_view name) +{ + const auto found = std::ranges::find(names, name); + return found != names.end() ? std::optional(static_cast(found - names.begin())) : std::nullopt; +} std::span GetMonsterStates() { diff --git a/src/source/Data/GameData/MonsterData/MonsterSetup.h b/src/source/Data/GameData/MonsterData/MonsterSetup.h index 42bc4a2663..2d213bdea0 100644 --- a/src/source/Data/GameData/MonsterData/MonsterSetup.h +++ b/src/source/Data/GameData/MonsterData/MonsterSetup.h @@ -1,7 +1,10 @@ #pragma once +#include "Data/GameData/Common/LocalizedString.h" + #include #include +#include #include #include #include @@ -27,6 +30,8 @@ inline constexpr int MaxTileOffset = 16; inline constexpr int MaxLookLevel = 65535; inline constexpr int MaxMoveSpeed = 65535; inline constexpr int MaxSpawnEffectCount = 100; +// The highest level an NPC's talk may ask for. +inline constexpr int MaxTalkLevel = 1000; inline constexpr int MaxSpawnEffectScaleRandom = 10000; inline constexpr int MaxFacingTile = 255; // An eyes row's bones: the right and left eye, then up to two more pairs. @@ -215,6 +220,111 @@ struct MonsterHookKind std::span GetMonsterHookKinds(); const MonsterHookKind* FindMonsterHookKind(std::string_view name); +// An NPC's minimum level to talk (MN5, Q-MN46b): below it the hero gets the +// message (with "%d" for the level) and does not talk. +struct MonsterTalkLevel +{ + int level = 0; + Data::LocalizedString message; + + bool operator==(const MonsterTalkLevel&) const = default; +}; + +// What a talk dialog's OK does (Q-MN46d): a request the client knows +// (GetTalkRequests), sent when the quest is in one of the states and the hero +// has the zen; without the zen the failure message. +struct MonsterTalkOk +{ + std::string request; + // One of GetTalkQuests, or empty for none; its states (GetTalkQuestStates). + std::string quest; + std::vector questStates; + unsigned long zen = 0; + Data::LocalizedString failure; + + bool operator==(const MonsterTalkOk&) const = default; +}; + +// A dialog an NPC shows when the hero talks to it: a bold title, its lines and +// what its OK does. +struct MonsterTalkDialog +{ + Data::LocalizedString title; + std::vector lines; + std::optional ok; + + bool operator==(const MonsterTalkDialog&) const = default; +}; + +// The texts of a Crywolf altar's contract (the hook CrywolfAltarContract, +// Q-MN46): how many contracts are left ("%d"), the question, the refusal on a +// mount, the answer to Cancel, a destroyed altar and an ongoing contract. +struct MonsterAltarContract +{ + Data::LocalizedString canContract; + Data::LocalizedString proceed; + Data::LocalizedString onMount; + Data::LocalizedString needGuardian; + Data::LocalizedString destroyed; + Data::LocalizedString ongoing; + + bool operator==(const MonsterAltarContract&) const = default; +}; + +// The talk texts several NPCs share, defined once with a name (the monster +// list's "talkTexts"): each set is an altar contract's texts, which the five +// Crywolf altars' steps name ("crywolfAltar"). +using MonsterTalkTexts = std::map>; + +// The keys of an altar contract's texts in the file, in MonsterAltarContract's +// order, and its texts in that order. +std::span GetAltarContractTextKeys(); +std::array AltarContractTexts(MonsterAltarContract& contract); +std::array AltarContractTexts(const MonsterAltarContract& contract); + +// One step of an NPC's talk, in order (D66, Q-MN46d). +struct MonsterTalkBlock +{ + enum class Kind + { + // Show a dialog. + Dialog, + // Send the talk request (the server opens the NPC's window). + SendTalk, + // Ask the server for the equipped Dark Horse's and Dark Raven's information. + SendPetInfo, + // Log a system message. + Message, + // The quest window shows the NPC's own name and no quest title for + // these classes (Marlon for the Dark Lord, Magic Gladiator and Rage + // Fighter, whose quest is another NPC's). + QuestWindow, + // A Crywolf altar's contract, which the map's code makes while only + // elves may (the hook CrywolfAltarContract), with the talk texts + // altarTexts names; on Crywolf otherwise nothing, elsewhere the talk + // request. + CrywolfAltarContract, + }; + + Kind kind = Kind::SendTalk; + MonsterTalkDialog dialog; + Data::LocalizedString message; + // Indexes of Items::GetClassKeys. + std::vector noQuestTitleFor; + // The name of the set of MonsterTalkTexts an altar contract shows. + std::string altarTexts; + + bool operator==(const MonsterTalkBlock&) const = default; +}; + +// The requests a dialog's OK may send, the quests it may check and their +// states, by the names the monster list gives them. +std::span GetTalkRequests(); +std::span GetTalkQuests(); +std::span GetTalkQuestStates(); +// The index of the name in its list, or nullopt. +std::optional FindTalkName(std::span names, std::string_view name); + struct MonsterSetup { // Object.Scale; or the scale a player has with the monster's equipment @@ -304,6 +414,17 @@ struct MonsterSetup std::vector stateVariants; // The maps' code it hands over to when it appears (Q-MN36d), in order. std::vector hooks; + // How an NPC talks (MN5, D66): whether it talks at all (else every NPC + // does), its minimum level, whether its shop repairs, and its talk's + // steps (none: it sends the talk request). + std::optional talks; + std::optional talkLevel; + std::optional repairShop; + std::vector onTalk; + // It takes part in Crywolf's hero contract: a buff the server sends again + // while it shows it (0x07) toggles eBuff_CrywolfHeroContracted and strips + // the other Crywolf states (the altars and the Crywolf NPCs). + std::optional crywolfContract; bool operator==(const MonsterSetup&) const = default; }; @@ -319,6 +440,43 @@ struct MonsterStateVariant bool operator==(const MonsterStateVariant&) const = default; }; +// Each text of an NPC's talk with its field, in order: "talkLevel.message", +// "onTalk[1].dialog.title", "onTalk[1].dialog.lines[0]", +// "onTalk[1].dialog.ok.failure", "onTalk[2].message". `setup` may be const. +template void ForEachTalkText(TSetup& setup, TVisit&& visit) +{ + if (setup.talkLevel) + visit(setup.talkLevel->message, std::string("talkLevel.message")); + for (size_t index = 0; index < setup.onTalk.size(); ++index) + { + auto& block = setup.onTalk[index]; + const std::string step = "onTalk[" + std::to_string(index) + "]"; + if (block.kind == MonsterTalkBlock::Kind::Message) + visit(block.message, step + ".message"); + if (block.kind != MonsterTalkBlock::Kind::Dialog) + continue; + if (!block.dialog.title.IsEmpty()) + visit(block.dialog.title, step + ".dialog.title"); + for (size_t line = 0; line < block.dialog.lines.size(); ++line) + visit(block.dialog.lines[line], step + ".dialog.lines[" + std::to_string(line) + "]"); + if (block.dialog.ok && !block.dialog.ok->failure.IsEmpty()) + visit(block.dialog.ok->failure, step + ".dialog.ok.failure"); + } +} + +// Each text of the shared talk texts with its field: +// "talkTexts.crywolfAltar.canContract". `texts` may be const. +template void ForEachSharedTalkText(TTexts& texts, TVisit&& visit) +{ + for (auto& [name, contract] : texts) + { + const auto values = AltarContractTexts(contract); + const std::span keys = GetAltarContractTextKeys(); + for (size_t index = 0; index < keys.size(); ++index) + visit(*values[index], "talkTexts." + name + "." + keys[index]); + } +} + // The states a state variant may name, as the game's code tells them // (World::Monsters::IsSpawnStateOn). std::span GetMonsterStates(); diff --git a/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp b/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp index b405c88fa0..01fcd85cd1 100644 --- a/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp +++ b/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp @@ -11,6 +11,7 @@ #include "Data/GameData/MonsterData/MonsterSoundIds.h" #include +#include #include #include #include @@ -50,6 +51,24 @@ constexpr const char* KeyKey = "key"; constexpr const char* StateKey = "state"; constexpr const char* HookKey = "hook"; constexpr const char* AngleKey = "angle"; +constexpr const char* LevelKey = "level"; +constexpr const char* MessageKey = "message"; +constexpr const char* DialogKey = "dialog"; +constexpr const char* SendKey = "send"; +constexpr const char* TalkSendKey = "talk"; +constexpr const char* PetInfoSendKey = "petInfo"; +constexpr const char* QuestWindowKey = "questWindow"; +constexpr const char* NoQuestTitleForKey = "noQuestTitleFor"; +constexpr const char* TitleKey = "title"; +constexpr const char* LinesKey = "lines"; +constexpr const char* OkKey = "ok"; +constexpr const char* RequestKey = "request"; +constexpr const char* IfKey = "if"; +constexpr const char* QuestKey = "quest"; +constexpr const char* IsKey = "is"; +constexpr const char* ZenKey = "zen"; +constexpr const char* FailureKey = "failure"; +constexpr const char* AltarContractKey = "crywolfAltarContract"; constexpr const char* TileXKey = "tileX"; constexpr const char* TileYKey = "tileY"; constexpr const char* FacingStepKey = "facingStep"; @@ -57,7 +76,6 @@ constexpr const char* SubTypeKey = "subType"; constexpr const char* ScaleKey = "scale"; constexpr const char* GroupKey = "group"; constexpr const char* NumberKey = "number"; -constexpr const char* LevelKey = "level"; constexpr const char* ExcellentKey = "excellent"; constexpr const char* PartKey = "part"; constexpr const char* MountLookKey = "mountLook"; @@ -724,6 +742,230 @@ struct Reader Appear(value, name, setup.teleport); else if (key == "gate") Gate(value, name, setup.gate); + else if (key == "talks") + Bool(value, name, setup.talks); + else if (key == "talkLevel") + TalkLevel(value, name, setup.talkLevel); + else if (key == "repairShop") + Bool(value, name, setup.repairShop); + else if (key == "onTalk") + OnTalk(value, name, setup.onTalk); + else if (key == "crywolfContract") + Bool(value, name, setup.crywolfContract); + } + + // A text in every language, or the English one. + void Text(const OrderedJson& value, const std::string& key, Data::LocalizedString& text) + { + text = Data::LocalizedString{}; + Items::Json::ReadLocalizedString(value, prefix + key, text, + [&](const std::string& field, const std::string& message) + { + problem(ItemDataIssueSeverity::Fatal, field, message); + valid = false; + }); + } + + // The NPC's minimum level and the message below it. + void TalkLevel(const OrderedJson& value, const std::string& key, std::optional& talkLevel) + { + if (!value.is_object() || !value.contains(LevelKey) || !value.contains(MessageKey)) + { + Fatal(key, "must be an object: level, message"); + return; + } + MonsterTalkLevel read; + const std::optional level = Json::ReadWholeIn(*value.find(LevelKey), 0, MaxTalkLevel); + if (!level) + { + Fatal(key + "." + LevelKey, Json::RangeText(0, MaxTalkLevel)); + return; + } + read.level = *level; + Text(*value.find(MessageKey), key + "." + MessageKey, read.message); + WarnUnknown(value, key, {LevelKey, MessageKey}); + talkLevel = std::move(read); + } + + // One of the names, or a fatal problem naming them. + std::optional Name(const OrderedJson& value, const std::string& key, std::span names) + { + const std::optional found = + value.is_string() ? FindTalkName(names, value.get()) : std::nullopt; + if (!found) + { + std::string list; + for (const std::string_view name : names) + list += (list.empty() ? "" : ", ") + std::string(name); + Fatal(key, "must be one of " + list); + } + return found; + } + + void TalkOk(const OrderedJson& value, const std::string& key, std::optional& ok) + { + if (!value.is_object() || !value.contains(RequestKey)) + { + Fatal(key, "must be an object: request, if, zen, failure"); + return; + } + MonsterTalkOk read; + if (const std::optional request = Name(*value.find(RequestKey), key + "." + RequestKey, GetTalkRequests())) + read.request = std::string(GetTalkRequests()[static_cast(*request)]); + if (const auto condition = value.find(IfKey); condition != value.end()) + { + const std::string conditionKey = key + "." + IfKey; + if (!condition->is_object() || !condition->contains(QuestKey) || !condition->contains(IsKey) || + !condition->find(IsKey)->is_array()) + { + Fatal(conditionKey, "must be an object: quest, is (a list of quest states)"); + } + else + { + if (const std::optional quest = + Name(*condition->find(QuestKey), conditionKey + "." + QuestKey, GetTalkQuests())) + read.quest = std::string(GetTalkQuests()[static_cast(*quest)]); + const OrderedJson& states = *condition->find(IsKey); + for (size_t i = 0; i < states.size(); ++i) + { + if (const std::optional state = + Name(states[i], conditionKey + "." + IsKey + "[" + std::to_string(i) + "]", + GetTalkQuestStates())) + read.questStates.emplace_back(GetTalkQuestStates()[static_cast(*state)]); + } + if (states.empty()) + Warning(conditionKey + "." + IsKey, "is empty: the OK sends nothing"); + WarnUnknown(*condition, conditionKey, {QuestKey, IsKey}); + } + } + if (const auto zen = value.find(ZenKey); zen != value.end()) + { + const std::optional amount = Json::ReadWholeIn(*zen, 0, std::numeric_limits::max()); + if (!amount) + Fatal(key + "." + ZenKey, "must be a whole number from 0"); + else + read.zen = static_cast(*amount); + } + if (const auto failure = value.find(FailureKey); failure != value.end()) + Text(*failure, key + "." + FailureKey, read.failure); + WarnUnknown(value, key, {RequestKey, IfKey, ZenKey, FailureKey}); + ok = std::move(read); + } + + void TalkDialog(const OrderedJson& value, const std::string& key, MonsterTalkDialog& dialog) + { + if (!value.is_object() || !value.contains(LinesKey) || !value.find(LinesKey)->is_array()) + { + Fatal(key, "must be an object: title, lines (a list of texts), ok"); + return; + } + if (const auto title = value.find(TitleKey); title != value.end()) + Text(*title, key + "." + TitleKey, dialog.title); + const OrderedJson& lines = *value.find(LinesKey); + for (size_t i = 0; i < lines.size(); ++i) + Text(lines[i], key + "." + LinesKey + "[" + std::to_string(i) + "]", dialog.lines.emplace_back()); + if (const auto ok = value.find(OkKey); ok != value.end()) + TalkOk(*ok, key + "." + OkKey, dialog.ok); + WarnUnknown(value, key, {TitleKey, LinesKey, OkKey}); + } + + // The talk's steps in order: each an object with one of dialog, send, + // message, questWindow or crywolfAltarContract. + void OnTalk(const OrderedJson& value, const std::string& key, std::vector& blocks) + { + if (!value.is_array()) + { + Fatal(key, "must be a list of talk steps: dialog, send, message, questWindow, crywolfAltarContract"); + return; + } + blocks.clear(); + for (size_t i = 0; i < value.size(); ++i) + { + const OrderedJson& row = value[i]; + const std::string rowKey = key + "[" + std::to_string(i) + "]"; + if (!row.is_object() || row.size() != 1) + { + Fatal(rowKey, "must be an object with one of dialog, send, message, questWindow, crywolfAltarContract"); + continue; + } + MonsterTalkBlock block; + const auto& [kind, content] = *row.items().begin(); + const std::string contentKey = rowKey + "." + kind; + if (kind == DialogKey) + { + block.kind = MonsterTalkBlock::Kind::Dialog; + TalkDialog(content, contentKey, block.dialog); + } + else if (kind == SendKey) + { + if (content == TalkSendKey) + block.kind = MonsterTalkBlock::Kind::SendTalk; + else if (content == PetInfoSendKey) + block.kind = MonsterTalkBlock::Kind::SendPetInfo; + else + { + Fatal(contentKey, std::string("must be ") + TalkSendKey + " or " + PetInfoSendKey); + continue; + } + } + else if (kind == MessageKey) + { + block.kind = MonsterTalkBlock::Kind::Message; + Text(content, contentKey, block.message); + } + else if (kind == QuestWindowKey) + { + block.kind = MonsterTalkBlock::Kind::QuestWindow; + const auto classes = content.is_object() ? content.find(NoQuestTitleForKey) : content.end(); + if (classes == content.end() || !classes->is_array()) + { + Fatal(contentKey, std::string("must be an object: ") + NoQuestTitleForKey + " (a list of classes)"); + continue; + } + const auto keys = Items::GetClassKeys(); + for (size_t c = 0; c < classes->size(); ++c) + { + const OrderedJson& name = (*classes)[c]; + const auto found = name.is_string() + ? std::ranges::find_if(keys, [&](const char* classKey) + { return name.get() == classKey; }) + : keys.end(); + if (found == keys.end()) + Fatal(contentKey + "." + NoQuestTitleForKey + "[" + std::to_string(c) + "]", + "must be a class: darkWizard, darkKnight, fairyElf, ..."); + else + block.noQuestTitleFor.push_back(static_cast(found - keys.begin())); + } + } + else if (kind == AltarContractKey) + { + block.kind = MonsterTalkBlock::Kind::CrywolfAltarContract; + // The name of its texts in the list's talkTexts, which the + // list's reader checks. + if (!content.is_string() || content.get().empty()) + { + Fatal(contentKey, "must be the name of a set of the list's talkTexts"); + continue; + } + block.altarTexts = content.get(); + } + else + { + Fatal(rowKey, "must be an object with one of dialog, send, message, questWindow, crywolfAltarContract"); + continue; + } + blocks.push_back(std::move(block)); + } + } + + void WarnUnknown(const OrderedJson& value, const std::string& key, std::initializer_list known) + { + Items::Json::KeySet keys; + for (const char* name : known) + keys.insert(name); + Items::Json::WarnAboutUnknownKeys(value, keys, prefix + key + " ", + [&](const std::string& unknown, const std::string& message) + { problem(ItemDataIssueSeverity::Warning, unknown, message); }); } // Each a tile coordinate, x or y, and the step there. @@ -865,6 +1107,67 @@ struct Reader } }; +OrderedJson WriteTalkBlock(const MonsterTalkBlock& block) +{ + OrderedJson row = OrderedJson::object(); + switch (block.kind) + { + case MonsterTalkBlock::Kind::Dialog: + { + OrderedJson dialog = OrderedJson::object(); + if (!block.dialog.title.IsEmpty()) + dialog[TitleKey] = Items::Json::WriteLocalizedString(block.dialog.title); + OrderedJson lines = OrderedJson::array(); + for (const Data::LocalizedString& line : block.dialog.lines) + lines.push_back(Items::Json::WriteLocalizedString(line)); + dialog[LinesKey] = std::move(lines); + if (block.dialog.ok) + { + const MonsterTalkOk& ok = *block.dialog.ok; + OrderedJson okJson = OrderedJson::object(); + okJson[RequestKey] = ok.request; + if (!ok.quest.empty()) + { + OrderedJson condition = OrderedJson::object(); + condition[QuestKey] = ok.quest; + condition[IsKey] = ok.questStates; + okJson[IfKey] = std::move(condition); + } + if (ok.zen > 0) + okJson[ZenKey] = ok.zen; + if (!ok.failure.IsEmpty()) + okJson[FailureKey] = Items::Json::WriteLocalizedString(ok.failure); + dialog[OkKey] = std::move(okJson); + } + row[DialogKey] = std::move(dialog); + break; + } + case MonsterTalkBlock::Kind::SendTalk: + row[SendKey] = TalkSendKey; + break; + case MonsterTalkBlock::Kind::SendPetInfo: + row[SendKey] = PetInfoSendKey; + break; + case MonsterTalkBlock::Kind::Message: + row[MessageKey] = Items::Json::WriteLocalizedString(block.message); + break; + case MonsterTalkBlock::Kind::CrywolfAltarContract: + row[AltarContractKey] = block.altarTexts; + break; + case MonsterTalkBlock::Kind::QuestWindow: + { + OrderedJson classes = OrderedJson::array(); + for (const int index : block.noQuestTitleFor) + classes.push_back(Items::GetClassKeys()[static_cast(index)]); + OrderedJson window = OrderedJson::object(); + window[NoQuestTitleForKey] = std::move(classes); + row[QuestWindowKey] = std::move(window); + break; + } + } + return row; +} + OrderedJson WriteItem(const MonsterItem& item) { OrderedJson json = OrderedJson::object(); @@ -1139,6 +1442,26 @@ OrderedJson WriteField(std::string_view key, const MonsterSetup& setup) } return list; } + if (key == "talks") + return WriteOptional(setup.talks); + if (key == "talkLevel") + { + OrderedJson talkLevel = OrderedJson::object(); + talkLevel[LevelKey] = setup.talkLevel->level; + talkLevel[MessageKey] = Items::Json::WriteLocalizedString(setup.talkLevel->message); + return talkLevel; + } + if (key == "repairShop") + return WriteOptional(setup.repairShop); + if (key == "crywolfContract") + return WriteOptional(setup.crywolfContract); + if (key == "onTalk") + { + OrderedJson list = OrderedJson::array(); + for (const MonsterTalkBlock& block : setup.onTalk) + list.push_back(WriteTalkBlock(block)); + return list; + } if (key == "stateVariants") { OrderedJson list = OrderedJson::array(); diff --git a/src/source/Data/GameData/MonsterData/MonstersJson.cpp b/src/source/Data/GameData/MonsterData/MonstersJson.cpp index 13cb927680..4cde9d6254 100644 --- a/src/source/Data/GameData/MonsterData/MonstersJson.cpp +++ b/src/source/Data/GameData/MonsterData/MonstersJson.cpp @@ -29,6 +29,7 @@ constexpr const char* UnknownGlowKey = "glow"; constexpr const char* UnknownMarksKey = "marks"; constexpr const char* UnknownMessageKey = "messageSeconds"; constexpr const char* UnknownForKey = "for"; +constexpr const char* TalkTextsKey = "talkTexts"; constexpr const char* ForNoEntry = "noEntry"; constexpr const char* ForNoModel = "noModel"; constexpr const char* ForMissingFile = "missingFile"; @@ -1133,6 +1134,103 @@ void ReadUnknownLook(const OrderedJson& root, UnknownMonsterLook& unknown, Issue [&](const std::string& unknownKey, const std::string& message) { list.Warning(unknownKey, message); }); } +// The shared talk texts: each set an object of an altar contract's six texts. +void ReadTalkTexts(const OrderedJson& root, MonsterTalkTexts& talkTexts, IssueList& list) +{ + talkTexts.clear(); + const auto section = root.find(TalkTextsKey); + if (section == root.end()) + return; + if (!section->is_object()) + { + list.Fatal(TalkTextsKey, "must be an object of talk text sets by name"); + return; + } + const std::span keys = GetAltarContractTextKeys(); + for (const auto& [name, set] : section->items()) + { + const std::string setKey = std::string(TalkTextsKey) + "." + name; + if (!set.is_object()) + { + list.Fatal(setKey, "must be an object of its texts: canContract, proceed, onMount, needGuardian, " + "destroyed, ongoing"); + // Kept empty, so the steps naming it add no problem of their own. + talkTexts.emplace(name, MonsterAltarContract{}); + continue; + } + // A set with a wrong text is kept, so its problem is the text's only + // and not also every step that names it. + MonsterAltarContract contract; + const auto texts = AltarContractTexts(contract); + for (size_t index = 0; index < keys.size(); ++index) + { + const std::string textKey = setKey + "." + keys[index]; + const auto text = set.find(keys[index]); + if (text == set.end()) + { + list.Fatal(textKey, "missing"); + continue; + } + Items::Json::ReadLocalizedString(*text, textKey, *texts[index], + [&](const std::string& field, const std::string& message) + { list.Fatal(field, message); }); + } + Items::Json::KeySet known(keys.begin(), keys.end()); + Items::Json::WarnAboutUnknownKeys(set, known, setKey + " ", + [&](const std::string& unknownKey, const std::string& message) + { list.Warning(unknownKey, message); }); + talkTexts.emplace(name, std::move(contract)); + } +} + +OrderedJson WriteTalkTexts(const MonsterTalkTexts& talkTexts) +{ + OrderedJson section = OrderedJson::object(); + const std::span keys = GetAltarContractTextKeys(); + for (const auto& [name, contract] : talkTexts) + { + OrderedJson set = OrderedJson::object(); + const auto texts = AltarContractTexts(contract); + for (size_t index = 0; index < keys.size(); ++index) + set[keys[index]] = Items::Json::WriteLocalizedString(*texts[index]); + section[name] = std::move(set); + } + return section; +} + +// An altar contract step names a set the talk texts have; a fatal problem +// once per field. +void CheckTalkTextNames(std::span monsters, const MonsterTalkTexts& talkTexts, IssueList& list) +{ + std::set reported; + const auto check = [&](const MonsterSetup& setup, int number, const std::string& where) + { + for (size_t index = 0; index < setup.onTalk.size(); ++index) + { + const MonsterTalkBlock& block = setup.onTalk[index]; + if (block.kind != MonsterTalkBlock::Kind::CrywolfAltarContract || talkTexts.contains(block.altarTexts)) + continue; + const std::string field = + MonsterField(number, (where + "onTalk[" + std::to_string(index) + "].crywolfAltarContract").c_str()); + if (reported.insert(field).second) + list.Fatal(field, "names " + block.altarTexts + ", which talkTexts does not have", number); + } + }; + for (const MonsterDefinition& entry : CollectNumberEntries(monsters)) + { + const auto withStates = [&](const MonsterSetup& setup, const std::string& where) + { + check(setup, entry.number, where); + for (size_t index = 0; index < setup.stateVariants.size(); ++index) + check(setup.stateVariants[index].setup, entry.number, + where + "stateVariants[" + std::to_string(index) + "]."); + }; + withStates(entry.setup, ""); + for (size_t index = 0; index < entry.mapVariants.size(); ++index) + withStates(entry.mapVariants[index].setup, "mapVariants[" + std::to_string(index) + "]."); + } +} + OrderedJson WriteUnknownLook(const UnknownMonsterLook& unknown) { OrderedJson section = OrderedJson::object(); @@ -1163,6 +1261,13 @@ void ReadMonstersJson(std::string_view text, const std::string& source, std::vec void ReadMonstersJson(std::string_view text, const std::string& source, std::vector& monsters, UnknownMonsterLook& unknown, std::vector& issues) +{ + MonsterTalkTexts talkTexts; + ReadMonstersJson(text, source, monsters, unknown, talkTexts, issues); +} + +void ReadMonstersJson(std::string_view text, const std::string& source, std::vector& monsters, + UnknownMonsterLook& unknown, MonsterTalkTexts& talkTexts, std::vector& issues) { IssueList list(source, issues); OrderedJson root; @@ -1170,10 +1275,11 @@ void ReadMonstersJson(std::string_view text, const std::string& source, std::vec { return; } - Items::Json::WarnAboutUnknownKeys(root, {Items::Json::Keys::FormatVersion, UnknownKey, MonstersKey}, "", - [&](const std::string& unknownKey, const std::string& message) + Items::Json::WarnAboutUnknownKeys(root, {Items::Json::Keys::FormatVersion, UnknownKey, TalkTextsKey, MonstersKey}, + "", [&](const std::string& unknownKey, const std::string& message) { list.Warning(unknownKey, message); }); ReadUnknownLook(root, unknown, list); + ReadTalkTexts(root, talkTexts, list); const auto entries = root.find(MonstersKey); if (entries == root.end() || !entries->is_array()) { @@ -1207,6 +1313,7 @@ void ReadMonstersJson(std::string_view text, const std::string& source, std::vec monsters.push_back(std::move(*monster)); } } + CheckTalkTextNames(monsters, talkTexts, list); } void ReadMonstersJson(std::string_view text, const std::string& source, std::vector& numbers, @@ -1220,7 +1327,8 @@ void ReadMonstersJson(std::string_view text, const std::string& source, std::vec namespace { -std::string WriteFile(std::span monsters, const UnknownMonsterLook* unknown) +std::string WriteFile(std::span monsters, const UnknownMonsterLook* unknown, + const MonsterTalkTexts* talkTexts) { std::vector sorted; sorted.reserve(monsters.size()); @@ -1243,6 +1351,8 @@ std::string WriteFile(std::span monsters, const UnknownMonst root[Items::Json::Keys::FormatVersion] = MonstersFormatVersion; if (unknown != nullptr) root[UnknownKey] = WriteUnknownLook(*unknown); + if (talkTexts != nullptr && !talkTexts->empty()) + root[TalkTextsKey] = WriteTalkTexts(*talkTexts); root[MonstersKey] = std::move(entries); const std::string text = root.dump(Items::Json::Indent, ' ', false, OrderedJson::error_handler_t::replace); std::string compact = Items::Json::PutObjectsOnOneLine(text, NameKey); @@ -1260,12 +1370,13 @@ std::string WriteFile(std::span monsters, const UnknownMonst std::string WriteMonstersJson(std::span monsters) { - return WriteFile(monsters, nullptr); + return WriteFile(monsters, nullptr, nullptr); } -std::string WriteMonstersJson(std::span monsters, const UnknownMonsterLook& unknown) +std::string WriteMonstersJson(std::span monsters, const UnknownMonsterLook& unknown, + const MonsterTalkTexts& talkTexts) { - return WriteFile(monsters, &unknown); + return WriteFile(monsters, &unknown, &talkTexts); } std::string WriteMonstersJson(std::span numbers) diff --git a/src/source/Data/GameData/MonsterData/MonstersJson.h b/src/source/Data/GameData/MonsterData/MonstersJson.h index 677428d4f4..cc4f52f3ba 100644 --- a/src/source/Data/GameData/MonsterData/MonstersJson.h +++ b/src/source/Data/GameData/MonsterData/MonstersJson.h @@ -34,7 +34,9 @@ // // "unknownMonsters" holds how the game shows a monster it cannot show as // itself (UnknownMonsterLook.h, D64); a file without it has the look's -// defaults. +// defaults. "talkTexts" holds the talk texts several NPCs share, by name +// (MonsterTalkTexts): a crywolfAltarContract step names one, and a name the +// section does not have is fatal. // // Version 1 listed each number with its values ("number" next to "name"); // such a file is read as a monster for each entry. @@ -50,16 +52,23 @@ void ReadMonstersJson(std::string_view text, const std::string& source, std::vec // The same, with the unknown monsters' look of the file. void ReadMonstersJson(std::string_view text, const std::string& source, std::vector& monsters, UnknownMonsterLook& unknown, std::vector& issues); +// The same, with the unknown monsters' look and the shared talk texts of the +// file. +void ReadMonstersJson(std::string_view text, const std::string& source, std::vector& monsters, + UnknownMonsterLook& unknown, MonsterTalkTexts& talkTexts, + std::vector& issues); // The entries of the numbers the text has, as ReadMonstersJson reads them. void ReadMonstersJson(std::string_view text, const std::string& source, std::vector& numbers, std::vector& issues); // The text of the file with these monsters, sorted by their first number, and -// their numbers sorted; without the unknown monsters' look. +// their numbers sorted; without the unknown monsters' look and talk texts. std::string WriteMonstersJson(std::span monsters); -// The same, with the unknown monsters' look (the file the game reads). -std::string WriteMonstersJson(std::span monsters, const UnknownMonsterLook& unknown); +// The same, with the unknown monsters' look and the shared talk texts (the +// file the game reads). +std::string WriteMonstersJson(std::span monsters, const UnknownMonsterLook& unknown, + const MonsterTalkTexts& talkTexts); // The text of the file with a monster for each entry. std::string WriteMonstersJson(std::span numbers); diff --git a/src/source/Engine/Object/ZzzInterface.cpp b/src/source/Engine/Object/ZzzInterface.cpp index d2bbfaa854..389e8640db 100644 --- a/src/source/Engine/Object/ZzzInterface.cpp +++ b/src/source/Engine/Object/ZzzInterface.cpp @@ -37,6 +37,11 @@ #include "GameLogic/Events/MatchEvent.h" #include "GameLogic/Items/PersonalShopTitleImp.h" +#include "Data/GameData/MonsterData/MonsterDatabase.h" +#include "Data/GameData/MonsterData/MonsterSetup.h" +#include "GameLogic/Monsters/MonsterSetupApply.h" +#include "World/GameMaps/MonsterSpawnWorld.h" +#include "GameLogic/NPCs/NpcTalk.h" #include "GameLogic/Quests/CSQuest.h" #include "GameLogic/Items/CSItemOption.h" #include "GameLogic/NPCs/npcBreeder.h" @@ -76,7 +81,7 @@ extern int g_iChatInputType; extern BOOL g_bUseChatListBox; extern DWORD g_dwMouseUseUIID; extern CUIMapName* g_pUIMapName; // rozy -extern bool bCheckNPC; +extern std::vector g_noQuestTitleClasses; extern BOOL g_bWhileMovingZone; extern DWORD g_dwLatestZoneMoving; extern bool LogOut; @@ -1230,6 +1235,180 @@ void SendRequestAction(OBJECT& obj, BYTE action) SocketClient->ToGameServer()->SendAnimationRequest(rotation, action); } +// The Crywolf altar dialog's button and the altar it asks for. +extern int Button_Down; +extern int BackUp_Key; + +namespace +{ +// The Crywolf altar contract an elf makes while only elves may (the hook +// CrywolfAltarContract, Q-MN46), with the texts of its talk step. +void CrywolfAltarTalk(CHARACTER& npc, const GameLogic::Npcs::CrywolfAltarTexts& texts) +{ + // Talk calls it for the altars' models only. + const int altarNum = npc.Object.Type - MODEL_CRYWOLF_ALTAR1; + const bool isElf = gCharacterManager.GetBaseClass(Hero->Class) == CLASS_ELF; + const bool altarActive = M34CryWolf1st::Get_AltarState_State(altarNum); + const BYTE state = (m_AltarState[altarNum] & 0x0f); + + if (isElf && !altarActive) + { + if (state > 0) + { + BackUp_Key = npc.Key; + + // The contracts left for the text's "%d". + const std::wstring canContract = GameLogic::Npcs::WithNumber(texts.canContract, state); + mu::ui::window::GenericDialogConfig cfg; + cfg.showCancel = true; + cfg.lines = { + {canContract, false}, + {texts.proceed, false}, + }; + cfg.onPrimary = [onMount = texts.onMount] + { + if (Hero->Helper.Type == MODEL_HORN_OF_UNIRIA || Hero->Helper.Type == MODEL_HORN_OF_DINORANT || + Hero->Helper.Type == MODEL_HORN_OF_FENRIR) + { + mu::ui::window::GenericDialogConfig dontCfg; + dontCfg.lines.push_back({onMount, false}); + mu::ui::window::g_pGenericConfirmDialog->Show(std::move(dontCfg)); + } + else + { + Button_Down = 2; + SocketClient->ToGameServer()->SendCrywolfContractRequest(BackUp_Key); + } + }; + cfg.onCancel = [needGuardian = texts.needGuardian] + { + Button_Down = 1; + mu::ui::window::GenericDialogConfig needGuardianCfg; + needGuardianCfg.lines.push_back({needGuardian, false}); + mu::ui::window::g_pGenericConfirmDialog->Show(std::move(needGuardianCfg)); + }; + mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); + } + else + { + mu::ui::window::GenericDialogConfig cfg; + cfg.lines.push_back({texts.destroyed, false}); + mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); + } + } + else if (isElf && altarActive) + { + mu::ui::window::GenericDialogConfig cfg; + cfg.lines.push_back({texts.ongoing, false}); + mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); + } + else + { + SocketClient->ToGameServer()->SendTalkToNpcRequest(npc.Key); + } +} + +// Kanturu 2nd's entrance: no talk while the gate machine animates. +void Kanturu2ndTalk(CHARACTER& npc) +{ + if (g_pKanturu2ndEnterNpc->IsNpcAnimation()) + return; + // Talking to another NPC replaces the gate dialog: release + // the server dialog BEFORE the new Talk request so packet + // order stays Close(old) then Talk(new). A Close sent later + // (e.g. from the new dialog's Show path) would clear the + // just-opened dialog instead. + if (g_pNewUISystem->IsVisible(mu::ui::window::INTERFACE_KANTURU2ND_ENTERNPC)) + { + g_pKanturu2ndEnterNpc->ClosingProcess(); + } + SocketClient->ToGameServer()->SendTalkToNpcRequest(npc.Key); +} + +// The Illusion Temple: only the event's own NPCs react, by its window. +void CursedTempleTalk(CHARACTER& npc) +{ + if (g_CursedTemple->IsGaugebarEnabled()) + return; + const int monsterIndex = npc.MonsterIndex; + if (monsterIndex == MONSTER_STONE_STATUE || (g_pCursedTempleWindow->CheckInventoryHolyItem(Hero) && + ((g_pCursedTempleWindow->GetMyTeam() == SEASON3A::eTeam_Allied && + monsterIndex == MONSTER_ALLIANCE_ITEM_STORAGE) || + (g_pCursedTempleWindow->GetMyTeam() == SEASON3A::eTeam_Illusion && + monsterIndex == MONSTER_ILLUSION_ITEM_STORAGE)))) + { + g_CursedTemple->SetGaugebarEnabled(true); + } + g_pCursedTempleWindow->CheckTalkProgressNpc(monsterIndex, npc.Key); +} + +// The game's side of talking to an NPC. +const GameLogic::Npcs::TalkWorld& GameTalkWorld() +{ + static const GameLogic::Npcs::TalkWorld world{ + .talkSetup = + [](const CHARACTER& npc) + { + const Data::Monsters::MonsterDefinition* entry = g_MonsterDatabase.Find(npc.MonsterIndex); + return entry != nullptr ? GameLogic::Monsters::Setup::SetupNow(*entry, World::Monsters::GetSpawnWorld()) + : Data::Monsters::MonsterSetup{}; + }, + .locale = [] { return g_MonsterDatabase.GetDisplayLocale(); }, + .findTalkTexts = [](const std::string& name) -> const Data::Monsters::MonsterAltarContract* + { + const Data::Monsters::MonsterTalkTexts& texts = g_MonsterDatabase.GetTalkTexts(); + const auto found = texts.find(name); + return found != texts.end() ? &found->second : nullptr; + }, + .heroLevel = [] { return static_cast(CharacterAttribute->Level); }, + // The state the Werewolf Guardsman's variant reads, so the two agree. + .isCrywolfElfTime = [] { return World::Monsters::IsSpawnStateOn("crywolfElfTime"); }, + .isCrywolfMap = [] { return M34CryWolf1st::IsCyrWolf1st(); }, + .isKanturu2nd = [] { return M38Kanturu2nd::Is_Kanturu2nd(); }, + .isCursedTemple = [] { return gMapManager.IsCursedTemple(); }, + .questState = [](int quest) { return static_cast(g_csQuest.getQuestState2(quest)); }, + .gold = [] { return static_cast(CharacterMachine->Gold); }, + .hasDarkHorse = [] { return CharacterMachine->Equipment[EQUIPMENT_HELPER].Type == ITEM_DARK_HORSE_ITEM; }, + .hasDarkRaven = [] { return CharacterMachine->Equipment[EQUIPMENT_WEAPON_LEFT].Type == ITEM_DARK_RAVEN_ITEM; }, + .sendTalk = [](int key) { SocketClient->ToGameServer()->SendTalkToNpcRequest(key); }, + .sendEnterRefuge = [] { SocketClient->ToGameServer()->SendEnterOnWerewolfRequest(); }, + .sendEnterBarracks = [] { SocketClient->ToGameServer()->SendEnterOnGatekeeperRequest(); }, + .sendDarkHorseInfo = + [] + { + SocketClient->ToGameServer()->SendPetInfoRequest(PetType::DarkHorse, StorageType::Inventory, + EQUIPMENT_HELPER); + }, + .sendDarkRavenInfo = + [] + { + SocketClient->ToGameServer()->SendPetInfoRequest(PetType::DarkRaven, StorageType::Inventory, + EQUIPMENT_WEAPON_LEFT); + }, + .showDialog = [](mu::ui::window::GenericDialogConfig config) + { mu::ui::window::g_pGenericConfirmDialog->Show(std::move(config)); }, + .systemMessage = [](const std::wstring& text) + { g_pSystemLogBox->AddText(text.c_str(), mu::ui::window::TYPE_SYSTEM_MESSAGE); }, + .errorMessage = [](const std::wstring& text) + { g_pSystemLogBox->AddText(text.c_str(), mu::ui::window::TYPE_ERROR_MESSAGE); }, + .setRepairShop = [](bool repair) { g_pNPCShop->SetRepairShop(repair); }, + .setNoQuestTitleFor = [](const std::vector& classes) { g_noQuestTitleClasses = classes; }, + .beforeTalk = + [] + { + if (g_pNewUISystem->IsVisible(mu::ui::window::INTERFACE_MYQUEST)) + g_pNewUISystem->Hide(mu::ui::window::INTERFACE_MYQUEST); + if (g_csQuest.IsInit()) + SocketClient->ToGameServer()->SendLegacyQuestStateRequest(); + }, + .crywolfAltarTalk = CrywolfAltarTalk, + .kanturu2ndTalk = Kanturu2ndTalk, + .cursedTempleTalk = CursedTempleTalk, + }; + return world; +} +} // namespace + int ItemKey = 0; int ActionTarget = -1; @@ -1540,244 +1719,13 @@ void Action(CHARACTER* c, OBJECT* o, bool Now) if (TargetNpc == -1) break; - // === Rozpoznanie napotkanego NPC === - const int npcIndex = 205; - const int monsterIndex = CharactersClient[TargetNpc].MonsterIndex; - - if (monsterIndex >= npcIndex) - { - const int level = CharacterAttribute->Level; - - if (monsterIndex == MONSTER_CHAOS_GOBLIN && level < 10) - { - wchar_t text[100]; - mu_swprintf(text, I18N::Game::OnlyLevelAboveDCanDoTheChaosCombination, CHAOS_MIX_LEVEL); - g_pSystemLogBox->AddText(text, mu::ui::window::TYPE_SYSTEM_MESSAGE); - break; - } - - // Sklepy - bool isRepairNpc = - monsterIndex == MONSTER_EO_THE_CRAFTSMAN || - monsterIndex == MONSTER_ZIENNA_THE_WEAPONS_MERCHANT || - monsterIndex == MONSTER_HANZO_THE_BLACKSMITH || - monsterIndex == MONSTER_RHEA || - monsterIndex == MONSTER_WEAPONS_MERCHANT_BOLO; - - g_pNPCShop->SetRepairShop(isRepairNpc); - - if (g_pNewUISystem->IsVisible(mu::ui::window::INTERFACE_MYQUEST)) - g_pNewUISystem->Hide(mu::ui::window::INTERFACE_MYQUEST); - - if (g_csQuest.IsInit()) - SocketClient->ToGameServer()->SendLegacyQuestStateRequest(); - - // === Specjalne rozmowy === - const int objectType = CharactersClient[TargetNpc].Object.Type; - if (isCryWolfElf) - { - if (objectType >= MODEL_CRYWOLF_ALTAR1 && objectType <= MODEL_CRYWOLF_ALTAR5) - { - const int altarNum = objectType - MODEL_CRYWOLF_ALTAR1; - const bool isElf = gCharacterManager.GetBaseClass(Hero->Class) == CLASS_ELF; - const bool altarActive = M34CryWolf1st::Get_AltarState_State(altarNum); - const BYTE state = (m_AltarState[altarNum] & 0x0f); - - if (isElf && !altarActive) - { - if (state > 0) - { - extern int Button_Down; - extern int BackUp_Key; - BackUp_Key = CharactersClient[TargetNpc].Key; - - wchar_t szCryWolfText[256]; - mu_swprintf(szCryWolfText, I18N::Game::ContractCanBeMadeForDTimes, state); - mu::ui::window::GenericDialogConfig cfg; - cfg.showCancel = true; - cfg.lines = { - { szCryWolfText, false }, - { I18N::Game::WouldYouLikeToProceedWithTheContract, false }, - }; - cfg.onPrimary = [] - { - if (Hero->Helper.Type == MODEL_HORN_OF_UNIRIA || Hero->Helper.Type == MODEL_HORN_OF_DINORANT || Hero->Helper.Type == MODEL_HORN_OF_FENRIR) - { - mu::ui::window::GenericDialogConfig dontCfg; - dontCfg.lines.push_back({ I18N::Game::ContractCanTBeMadeWhenYouAreOnAMount, false }); - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(dontCfg)); - } - else - { - Button_Down = 2; - SocketClient->ToGameServer()->SendCrywolfContractRequest(BackUp_Key); - } - }; - cfg.onCancel = [] - { - Button_Down = 1; - mu::ui::window::GenericDialogConfig needGuardianCfg; - needGuardianCfg.lines.push_back({ I18N::Game::WeNeedAGuardianToProtectTheWolf, false }); - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(needGuardianCfg)); - }; - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); - } - else - { - mu::ui::window::GenericDialogConfig cfg; - cfg.lines.push_back({ I18N::Game::FurtherContractCanTBeDoneSinceTheAltarHasBeenDestroyed, false }); - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); - } - } - else if (isElf && altarActive) - { - mu::ui::window::GenericDialogConfig cfg; - cfg.lines.push_back({ I18N::Game::ContractIsOngoingThereforeDualCompactIsNotPossible, false }); - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); - } - else - { - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - } - } - else - { - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - } - } - else if (M34CryWolf1st::IsCyrWolf1st()) - { - if (!(objectType >= MODEL_CRYWOLF_ALTAR1 && objectType <= MODEL_CRYWOLF_ALTAR5)) - { - if (objectType == MODEL_NPC_QUARREL) - { - mu::ui::window::GenericDialogConfig cfg; - cfg.lines = { - { I18N::Game::WerewolfGuardsman, true }, - { I18N::Game::DoYouEvenKnowAboutMe, false }, - { I18N::Game::IfYouHavePassedThroughThe, false }, - { I18N::Game::YouMustBeLocatedCloselyTogether, false }, - { I18N::Game::InOrderToReceiveHelpFrom, false }, - }; - cfg.onPrimary = [] - { - // Original visually disabled (LockOkButton) the OK button in this - // quest state instead of gating inside the callback -- the new - // primitive has no disabled-button concept yet, so this guard - // substitutes for that (functionally equivalent, cosmetically not). - BYTE byQuestState = g_csQuest.getQuestState2(QUEST_3RD_CHANGE_UP_2); - if (QUEST_ING != byQuestState && QUEST_END != byQuestState) - return; - - DWORD dwGold = CharacterMachine->Gold; - if (dwGold >= 3000000) - SocketClient->ToGameServer()->SendEnterOnWerewolfRequest(); - else - g_pSystemLogBox->AddText(I18N::Game::YouAreShortOfZen, mu::ui::window::TYPE_ERROR_MESSAGE); - }; - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); - } - - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - } - } - else if (SEASON3A::CGM3rdChangeUp::Instance().IsBalgasBarrackMap()) - { - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - - { - mu::ui::window::GenericDialogConfig cfg; - cfg.lines = { - { I18N::Game::Gatekeeper, true }, - { I18N::Game::HmmWhoAreYouIMConfusedAreYouEvenApprovedOfBalgass, false }, - { I18N::Game::LugadrS12ApostlesAreHelping, false }, - { I18N::Game::ApostleDevinSThirdMissionRequest, false }, - }; - cfg.onPrimary = [] - { - // Same disabled-OK-button substitution as the Werewolf dialog above. - BYTE byQuestState = g_csQuest.getQuestState2(QUEST_3RD_CHANGE_UP_3); - if (QUEST_ING != byQuestState) - return; - SocketClient->ToGameServer()->SendEnterOnGatekeeperRequest(); - }; - mu::ui::window::g_pGenericConfirmDialog->Show(std::move(cfg)); - } - } - else if (monsterIndex >= MONSTER_LITTLE_SANTA_YELLOW && monsterIndex <= MONSTER_LITTLE_SANTA_PINK) - { - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - - wchar_t temp[32] = { 0 }; - if (monsterIndex == MONSTER_LITTLE_SANTA_RED) - mu_swprintf(temp, I18N::Game::HealthHasBeenRecoveredOf100, 100); - else if (monsterIndex == MONSTER_LITTLE_SANTA_BLUE) - mu_swprintf(temp, I18N::Game::ManaHasBeenRecoveredOf100, 100); - - g_pSystemLogBox->AddText(temp, mu::ui::window::TYPE_SYSTEM_MESSAGE); - } - else if (monsterIndex == MONSTER_DELGADO || monsterIndex == MONSTER_LUGARD || - monsterIndex == MONSTER_MARKET_UNION_MEMBER_JULIA || monsterIndex == MONSTER_DAVID) - { - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - } - else - { - if (M38Kanturu2nd::Is_Kanturu2nd()) - { - if (!g_pKanturu2ndEnterNpc->IsNpcAnimation()) - { - // Talking to another NPC replaces the gate dialog: release - // the server dialog BEFORE the new Talk request so packet - // order stays Close(old) then Talk(new). A Close sent later - // (e.g. from the new dialog's Show path) would clear the - // just-opened dialog instead. - if (g_pNewUISystem->IsVisible(mu::ui::window::INTERFACE_KANTURU2ND_ENTERNPC)) - { - g_pKanturu2ndEnterNpc->ClosingProcess(); - } - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - } - } - else if (gMapManager.IsCursedTemple()) - { - if (!g_CursedTemple->IsGaugebarEnabled()) - { - if ( - CharactersClient[TargetNpc].MonsterIndex == MONSTER_STONE_STATUE || - (g_pCursedTempleWindow->CheckInventoryHolyItem(Hero) && - ((g_pCursedTempleWindow->GetMyTeam() == SEASON3A::eTeam_Allied && monsterIndex == MONSTER_ALLIANCE_ITEM_STORAGE) || - (g_pCursedTempleWindow->GetMyTeam() == SEASON3A::eTeam_Illusion && monsterIndex == MONSTER_ILLUSION_ITEM_STORAGE))) - ) - { - g_CursedTemple->SetGaugebarEnabled(true); - } - g_pCursedTempleWindow->CheckTalkProgressNpc(monsterIndex, CharactersClient[TargetNpc].Key); - } - } - else - { - SocketClient->ToGameServer()->SendTalkToNpcRequest(CharactersClient[TargetNpc].Key); - } - } - - bCheckNPC = (monsterIndex == MONSTER_MARLON); - - if (monsterIndex == MONSTER_PET_TRAINER) - { - ITEM* pItem = &CharacterMachine->Equipment[EQUIPMENT_HELPER]; - if (pItem->Type == ITEM_DARK_HORSE_ITEM) - SocketClient->ToGameServer()->SendPetInfoRequest(PetType::DarkHorse, StorageType::Inventory, EQUIPMENT_HELPER); - - pItem = &CharacterMachine->Equipment[EQUIPMENT_WEAPON_LEFT]; - if (pItem->Type == ITEM_DARK_RAVEN_ITEM) - SocketClient->ToGameServer()->SendPetInfoRequest(PetType::DarkRaven, StorageType::Inventory, EQUIPMENT_WEAPON_LEFT); - } - } + // A rule that stops before the talk keeps the NPC as the target. + if (!GameLogic::Npcs::Talk(CharactersClient[TargetNpc], GameTalkWorld())) + break; - TargetNpc = -1; - break; - } + TargetNpc = -1; + break; + } case MOVEMENT_OPERATE: if (std::max(abs((Hero->PositionX) - TargetX), abs((Hero->PositionY) - TargetY)) <= 1) { diff --git a/src/source/Engine/Object/ZzzInterface.h b/src/source/Engine/Object/ZzzInterface.h index 524359c67e..f0f791a407 100644 --- a/src/source/Engine/Object/ZzzInterface.h +++ b/src/source/Engine/Object/ZzzInterface.h @@ -66,7 +66,6 @@ void ClearInput(BOOL bClearWhisperTarget = TRUE); // area forgets them. void CollectCrownSwitchLines(std::wstring (&lines)[2]); - // HotKey int FindHotKey(int Skill); diff --git a/src/source/GameLogic/NPCs/NpcTalk.cpp b/src/source/GameLogic/NPCs/NpcTalk.cpp new file mode 100644 index 0000000000..933c34179c --- /dev/null +++ b/src/source/GameLogic/NPCs/NpcTalk.cpp @@ -0,0 +1,163 @@ +#include "stdafx.h" + +#include "NpcTalk.h" + +#include "Core/Globals/_define.h" +#include "Core/Globals/_enum.h" +#include "Core/Text/Utf8.h" +#include "Data/GameData/MonsterData/MonsterSetup.h" +#include "Engine/Object/w_CharacterInfo.h" +#include "UI/Dialogs/GenericConfirmDialog.h" + +#include +#include + +namespace GameLogic::Npcs +{ +namespace +{ +using Data::Monsters::MonsterTalkBlock; +using Data::Monsters::MonsterTalkDialog; +using Data::Monsters::MonsterTalkOk; + +std::wstring TextOf(const Data::LocalizedString& text, const TalkWorld& world) +{ + return Core::Text::FromUtf8(text.Get(world.locale())); +} + +// The talk request, as the map lets it: Kanturu 2nd's entrance and the +// Illusion Temple's NPCs have rules of their own. +void SendTalk(CHARACTER& npc, const TalkWorld& world) +{ + if (world.isKanturu2nd()) + world.kanturu2ndTalk(npc); + else if (world.isCursedTemple()) + world.cursedTempleTalk(npc); + else + world.sendTalk(npc.Key); +} + +// The OK of a dialog: its quest in one of the states, then the zen. +void RunOk(const MonsterTalkOk& ok, const TalkWorld& world) +{ + if (!ok.quest.empty()) + { + const std::optional quest = Data::Monsters::FindTalkName(Data::Monsters::GetTalkQuests(), ok.quest); + const int state = quest ? world.questState(*quest) : -1; + const bool inState = std::ranges::any_of( + ok.questStates, [&](const std::string& name) + { return Data::Monsters::FindTalkName(Data::Monsters::GetTalkQuestStates(), name) == state; }); + // The OK did nothing in another state, as the old disabled button. + if (!inState) + return; + } + if (world.gold() < ok.zen) + { + if (!ok.failure.IsEmpty()) + world.errorMessage(TextOf(ok.failure, world)); + return; + } + if (ok.request == "enterRefuge") + world.sendEnterRefuge(); + else if (ok.request == "enterBarracks") + world.sendEnterBarracks(); +} + +mu::ui::window::GenericDialogConfig DialogOf(const MonsterTalkDialog& dialog, const TalkWorld& world) +{ + mu::ui::window::GenericDialogConfig config; + if (!dialog.title.IsEmpty()) + config.lines.push_back({TextOf(dialog.title, world), true}); + for (const Data::LocalizedString& line : dialog.lines) + config.lines.push_back({TextOf(line, world), false}); + if (dialog.ok) + config.onPrimary = [ok = *dialog.ok, &world] { RunOk(ok, world); }; + return config; +} + +void RunBlock(const MonsterTalkBlock& block, CHARACTER& npc, const TalkWorld& world) +{ + switch (block.kind) + { + case MonsterTalkBlock::Kind::Dialog: + world.showDialog(DialogOf(block.dialog, world)); + break; + case MonsterTalkBlock::Kind::SendTalk: + SendTalk(npc, world); + break; + case MonsterTalkBlock::Kind::SendPetInfo: + if (world.hasDarkHorse()) + world.sendDarkHorseInfo(); + if (world.hasDarkRaven()) + world.sendDarkRavenInfo(); + break; + case MonsterTalkBlock::Kind::Message: + world.systemMessage(TextOf(block.message, world)); + break; + case MonsterTalkBlock::Kind::QuestWindow: + world.setNoQuestTitleFor(block.noQuestTitleFor); + break; + case MonsterTalkBlock::Kind::CrywolfAltarContract: + // The step on an NPC that is no altar (the data allows it, Data + // Checks warns): it talks. + if (npc.Object.Type < MODEL_CRYWOLF_ALTAR1 || npc.Object.Type > MODEL_CRYWOLF_ALTAR5) + { + SendTalk(npc, world); + break; + } + // Its contract while only elves may make it, nothing else on + // Crywolf, the talk request elsewhere. + if (world.isCrywolfElfTime()) + { + // The list's reader and Data Checks report a set that is not + // there; it shows empty texts then. + const Data::Monsters::MonsterAltarContract* found = world.findTalkTexts(block.altarTexts); + static const Data::Monsters::MonsterAltarContract None; + const Data::Monsters::MonsterAltarContract& texts = found != nullptr ? *found : None; + world.crywolfAltarTalk(npc, {TextOf(texts.canContract, world), TextOf(texts.proceed, world), + TextOf(texts.onMount, world), TextOf(texts.needGuardian, world), + TextOf(texts.destroyed, world), TextOf(texts.ongoing, world)}); + } + else if (!world.isCrywolfMap()) + { + SendTalk(npc, world); + } + break; + } +} +} // namespace + +std::wstring WithNumber(std::wstring text, int number) +{ + const std::wstring shown = std::to_wstring(number); + for (size_t at = text.find(L"%d"); at != std::wstring::npos; at = text.find(L"%d", at + shown.size())) + text.replace(at, 2, shown); + return text; +} + +bool Talk(CHARACTER& npc, const TalkWorld& world) +{ + const Data::Monsters::MonsterSetup setup = world.talkSetup(npc); + if (!setup.talks.value_or(true)) + return true; + + if (setup.talkLevel && world.heroLevel() < setup.talkLevel->level) + { + world.systemMessage(WithNumber(TextOf(setup.talkLevel->message, world), setup.talkLevel->level)); + return false; + } + + world.setRepairShop(setup.repairShop.value_or(false)); + world.beforeTalk(); + world.setNoQuestTitleFor({}); + + if (setup.onTalk.empty()) + { + SendTalk(npc, world); + return true; + } + for (const MonsterTalkBlock& block : setup.onTalk) + RunBlock(block, npc, world); + return true; +} +} // namespace GameLogic::Npcs diff --git a/src/source/GameLogic/NPCs/NpcTalk.h b/src/source/GameLogic/NPCs/NpcTalk.h new file mode 100644 index 0000000000..7b2ffeb0a0 --- /dev/null +++ b/src/source/GameLogic/NPCs/NpcTalk.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include + +class CHARACTER; + +namespace Data::Monsters +{ +struct MonsterSetup; +struct MonsterAltarContract; +} // namespace Data::Monsters + +namespace mu::ui::window +{ +struct GenericDialogConfig; +} + +// What the client does when the hero talks to an NPC (MOVEMENT_TALK), before +// the server answers: the NPC's talk fields in the monster data (MN5, D66), +// and the map rules that stay code. The game's side effects go through a +// TalkWorld, so the monster tests can record them (D50). +namespace GameLogic::Npcs +{ +// A Crywolf altar contract's texts in the shown language; canContract has +// "%d" for the contracts left. +struct CrywolfAltarTexts +{ + std::wstring canContract; + std::wstring proceed; + std::wstring onMount; + std::wstring needGuardian; + std::wstring destroyed; + std::wstring ongoing; +}; + +struct TalkWorld +{ + // What the rules read. + // The NPC's setup now: its entry's on the map and in the states that are on. + std::function talkSetup; + // The language its texts are shown in. + std::function locale; + // The shared talk texts of that name (the list's talkTexts); nullptr for + // none. + std::function findTalkTexts; + std::function heroLevel; + // The Crywolf 1st map while only elves may make the altar contracts. + std::function isCrywolfElfTime; + std::function isCrywolfMap; + std::function isKanturu2nd; + std::function isCursedTemple; + // A quest's state (QUEST_ING, QUEST_END, ...) and the hero's zen. + std::function questState; + std::function gold; + std::function hasDarkHorse; + std::function hasDarkRaven; + + // What they do. + std::function sendTalk; + std::function sendEnterRefuge; + std::function sendEnterBarracks; + std::function sendDarkHorseInfo; + std::function sendDarkRavenInfo; + std::function showDialog; + std::function systemMessage; + std::function errorMessage; + std::function setRepairShop; + // The classes whose quest window shows the NPC's own name and no quest + // title (Marlon's, Items::GetClassKeys indexes); empty for the others. + std::function& classes)> setNoQuestTitleFor; + // Every talk: the quest window closes and the legacy quest state is asked. + std::function beforeTalk; + // The map code that stays: the Crywolf altar contract, Kanturu 2nd's + // entrance, the Illusion Temple's NPCs. + std::function crywolfAltarTalk; + std::function kanturu2ndTalk; + std::function cursedTempleTalk; +}; + +// A talk text with each "%d" as the number (a text, not a format: a typed +// "%" cannot break it). +std::wstring WithNumber(std::wstring text, int number); + +// The hero talks to the NPC. False when a rule stops before the talk (its +// minimum level): the hero keeps the NPC as its target. +bool Talk(CHARACTER& npc, const TalkWorld& world); +} // namespace GameLogic::Npcs diff --git a/src/source/GameLogic/Quests/CSQuest.cpp b/src/source/GameLogic/Quests/CSQuest.cpp index 6398e0e246..4af7f6ec2a 100644 --- a/src/source/GameLogic/Quests/CSQuest.cpp +++ b/src/source/GameLogic/Quests/CSQuest.cpp @@ -35,7 +35,9 @@ #include "UI/Core/WindowSystem.h" #include "Render/Text/CUIRenderText.h" -bool bCheckNPC = false; +// The classes whose quest window shows the talked NPC's own name and no quest +// title (an NPC's questWindow talk step, Marlon's); empty for other NPCs. +std::vector g_noQuestTitleClasses; extern int g_iMessageTextStart; extern char g_cMessageTextCurrNum; extern char g_cMessageTextNum; @@ -290,7 +292,7 @@ bool CSQuest::OpenQuestScript(const wchar_t* filename) void CSQuest::setQuestLists(const std::uint8_t* byList, int num, CLASS_TYPE Class) { m_bOnce = true; - bCheckNPC = false; + g_noQuestTitleClasses.clear(); m_byClass = gCharacterManager.GetBaseClass(Class); m_byQuestList.fill(0); diff --git a/src/source/Network/Server/WSclient.cpp b/src/source/Network/Server/WSclient.cpp index 7bb9e2e91a..ce47298f5a 100644 --- a/src/source/Network/Server/WSclient.cpp +++ b/src/source/Network/Server/WSclient.cpp @@ -51,6 +51,8 @@ #include "GameLogic/Events/MatchEvent.h" #include "GameLogic/Monsters/MonsterAttackPlay.h" #include "GameLogic/Monsters/MonsterFacing.h" +#include "Data/GameData/MonsterData/MonsterDatabase.h" +#include "Data/GameData/MonsterData/MonsterSetup.h" #include "GameLogic/Monsters/MonsterSetupApply.h" #include "World/GameMaps/MonsterSpawnWorld.h" #include "Engine/AI/GOBoid.h" @@ -3774,9 +3776,13 @@ void ReceiveSkillStatus(const BYTE* ReceiveBuffer) { // Shown already (a monster's spawn buff): the server's now too. GameLogic::Monsters::Setup::NoteServerBuff(*o, static_cast(bufftype), true); - if ((o->Type >= MODEL_CRYWOLF_ALTAR1 && o->Type <= MODEL_CRYWOLF_ALTAR5) || o->Type == MODEL_SMITH || - o->Type == MODEL_NPC_SERBIS || o->Type == MODEL_MERCHANT_MAN || o->Type == MODEL_STORAGE || - o->Type == MODEL_NPC_BREEDER) + // The altars and the Crywolf NPCs take part in the hero contract + // (their entries' crywolfContract). + const Data::Monsters::MonsterDefinition* entry = g_MonsterDatabase.Find(c->MonsterIndex); + if (entry != nullptr && GameLogic::Monsters::Setup::SetupWithField( + *entry, [](const Data::Monsters::MonsterSetup& setup) + { return setup.crywolfContract.has_value(); }, World::Monsters::GetSpawnWorld()) + .crywolfContract.value_or(false)) { if (g_isCharacterBuff(o, eBuff_CrywolfHeroContracted)) { diff --git a/src/source/UI/Quests/NPCQuest.cpp b/src/source/UI/Quests/NPCQuest.cpp index d51e91f370..e9b3be3a73 100644 --- a/src/source/UI/Quests/NPCQuest.cpp +++ b/src/source/UI/Quests/NPCQuest.cpp @@ -28,7 +28,10 @@ #include #include -extern bool bCheckNPC; +#include +#include + +extern std::vector g_noQuestTitleClasses; extern int g_iNumLineMessageBoxCustom; extern int g_iNumAnswer; extern wchar_t g_lpszMessageBoxCustom[NUM_LINE_CMB][MAX_LENGTH_CMB]; @@ -371,8 +374,8 @@ void CNPCQuest::SyncRmlModel() const BYTE byCurQuestIndex = g_csQuest.GetCurrQuestIndex(); const BYTE byCurQuestState = g_csQuest.getQuestState2(int(byCurQuestIndex)); - const bool bDarkNpcCheck = (Hero->Class == CLASS_DARK_LORD || Hero->Class == CLASS_DARK - || Hero->Class == CLASS_RAGEFIGHTER) && bCheckNPC; + const bool bDarkNpcCheck = + std::ranges::find(g_noQuestTitleClasses, static_cast(Hero->Class)) != g_noQuestTitleClasses.end(); model.npcName = StringUtils::WideToNarrow(bDarkNpcCheck ? g_csQuest.GetNPCName(2) : g_csQuest.GetNPCName(byCurQuestIndex)); m_RmlView.MarkDirty("npc_name"); diff --git a/src/source/World/GameMaps/MonsterSpawnWorld.cpp b/src/source/World/GameMaps/MonsterSpawnWorld.cpp index c6347ccd58..6debb4f9b1 100644 --- a/src/source/World/GameMaps/MonsterSpawnWorld.cpp +++ b/src/source/World/GameMaps/MonsterSpawnWorld.cpp @@ -7,6 +7,7 @@ #include "GameLogic/Events/Cinematic/CDirection.h" #include "GameLogic/Events/w_CursedTemple.h" #include "World/GameMaps/GMBattleCastle.h" +#include "World/GameMaps/GMCrywolf1st.h" #include "World/GameMaps/GMEmpireGuardian1.h" #include "World/GameMaps/GMEmpireGuardian2.h" #include "World/GameMaps/GMEmpireGuardian3.h" @@ -67,6 +68,8 @@ bool IsSpawnStateOn(std::string_view state) return g_playerTransformed; if (state == "crywolfReady") return g_Direction.m_CMVP.m_iCryWolfState == CRYWOLF_STATE_READY; + if (state == "crywolfElfTime") + return M34CryWolf1st::IsCyrWolf1st() && M34CryWolf1st::Get_State_Only_Elf(); return false; } diff --git a/tests/data/test_monster_data_files.cpp b/tests/data/test_monster_data_files.cpp index 5b49a595ad..7127cd0b52 100644 --- a/tests/data/test_monster_data_files.cpp +++ b/tests/data/test_monster_data_files.cpp @@ -65,7 +65,7 @@ TEST_CASE("Shipped monster list: written in the writer's format [data][monsters] const auto text = Data::ReadTextFile(file); REQUIRE(text.has_value()); - CHECK(Data::Monsters::WriteMonstersJson(loaded.groups, loaded.unknown) == *text); + CHECK(Data::Monsters::WriteMonstersJson(loaded.groups, loaded.unknown, loaded.talkTexts) == *text); } TEST_CASE("Shipped monster list: the names the old name file test asked for [data][monsters]") @@ -199,12 +199,12 @@ TEST_CASE("Monster data: saving writes what loads back, and refuses errors [data std::vector monsters = {monster}; std::vector issues; - CHECK(Data::Monsters::SaveMonstersFile(file, Data::Monsters::MakeOneNumberGroups(monsters), {}, issues)); + CHECK(Data::Monsters::SaveMonstersFile(file, Data::Monsters::MakeOneNumberGroups(monsters), {}, {}, issues)); CHECK(Data::Monsters::LoadMonstersFile(file).monsters == monsters); monsters[0].names.Set("en", "Budge||Dragon"); issues.clear(); - CHECK_FALSE(Data::Monsters::SaveMonstersFile(file, Data::Monsters::MakeOneNumberGroups(monsters), {}, issues)); + CHECK_FALSE(Data::Monsters::SaveMonstersFile(file, Data::Monsters::MakeOneNumberGroups(monsters), {}, {}, issues)); CHECK(HasFatal(issues)); CHECK(Data::Monsters::LoadMonstersFile(file).monsters[0].names.GetNeutral() == "Budge Dragon"); CHECK_FALSE(std::filesystem::exists(file.string() + ".tmp")); diff --git a/tests/data/test_monster_json.cpp b/tests/data/test_monster_json.cpp index bb1da2a7c1..bb26e3563b 100644 --- a/tests/data/test_monster_json.cpp +++ b/tests/data/test_monster_json.cpp @@ -745,6 +745,67 @@ TEST_CASE("Monster data: maps that are no list of maps are an error, a monster's CHECK(shared.monsters[0].numbers[0].maps.empty()); } +// A talk text without a text of its own in one of the game's languages shows +// the English one there: a warning naming them (Q-MN47). +TEST_CASE("Monster data: a talk text missing a language is a warning (Q-MN47) [data][monsters]") +{ + MonsterDefinition santa = Monster(470, "Little Santa"); + Data::Monsters::MonsterTalkBlock message; + message.kind = Data::Monsters::MonsterTalkBlock::Kind::Message; + message.message.Set("en", "Health has been recovered."); + message.message.Set("de", "Leben wiederhergestellt."); + santa.setup.onTalk = {message}; + MonsterDefinition whole = santa; + whole.number = 471; + whole.setup.onTalk[0].message.Set("es", "Vida recuperada."); + const std::array monsters = {&santa, &whole}; + const std::array locales = {"en", "de", "es"}; + std::vector issues; + + Data::Monsters::MonsterTalkTexts shared; + Data::Monsters::MonsterAltarContract& altar = shared["crywolfAltar"]; + for (Data::LocalizedString* text : Data::Monsters::AltarContractTexts(altar)) + { + text->Set("en", "Text"); + text->Set("de", "Text"); + text->Set("es", "Texto"); + } + altar.ongoing = Data::LocalizedString{}; + altar.ongoing.Set("en", "Busy."); + + Data::Monsters::ValidateMonsterTalkTexts(monsters, shared, locales, "Monsters.json", issues); + + REQUIRE(issues.size() == 2); + CHECK(issues[0].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[0].field == "talkTexts.crywolfAltar.ongoing"); + CHECK(issues[0].message.find("de, es") != std::string::npos); + CHECK(issues[1].field == "monster 470 onTalk[0].message"); + CHECK(issues[1].message.find("es") != std::string::npos); +} + +// A variant's talk text is named as Monster Translations' Talk texts rows name +// it, so a field copied from the warning finds the row. +TEST_CASE("Monster data: a variant's talk text missing a language is named as its row [data][monsters]") +{ + MonsterDefinition guard = Monster(407, "Werewolf Guardsman"); + Data::Monsters::MonsterStateVariant state; + state.state = "crywolfElfTime"; + state.setup.onTalk.emplace_back().kind = Data::Monsters::MonsterTalkBlock::Kind::Message; + state.setup.onTalk[0].message.Set("en", "Elf time."); + Data::Monsters::MonsterMapVariant variant; + variant.maps = {Data::Maps::MapRef{34, 0}}; + variant.setup.stateVariants = {state}; + guard.mapVariants.push_back(variant); + const std::array monsters = {&guard}; + const std::array locales = {"en", "de"}; + std::vector issues; + + Data::Monsters::ValidateMonsterTalkTexts(monsters, {}, locales, "Monsters.json", issues); + + REQUIRE(issues.size() == 1); + CHECK(issues[0].field == "monster 407 mapVariants[0].stateVariants[0].onTalk[0].message"); +} + // Old behaviours the setup keeps are warnings (Q-MN36): `live` false drops the // character when it appears (the Wolf Status, P24), wherever it is set. TEST_CASE("Monster data: a setup that drops the monster when it appears is a warning (P24) [data][monsters]") @@ -779,3 +840,68 @@ TEST_CASE("Monster data: a setup that drops the monster when it appears is a war CHECK(issues[1].field == "monster 205 (map variant 1) live"); CHECK(issues[2].field == "monster 206 (state crywolfReady) live"); } + +// The talk steps as the editor leaves them: an OK quest without a state, a +// warning; an altar step naming a set that is not there, an error. +TEST_CASE("Monster data: Data Checks finds an OK quest without a state and an unknown talk text set [data][monsters]") +{ + MonsterDefinition guard = Monster(407, "Werewolf Guardsman"); + Data::Monsters::MonsterTalkBlock dialog; + dialog.kind = Data::Monsters::MonsterTalkBlock::Kind::Dialog; + dialog.dialog.ok.emplace().quest = "thirdChangeUp2"; + Data::Monsters::MonsterMapVariant variant; + variant.maps = {Data::Maps::MapRef{34, 0}}; + variant.setup.onTalk = {dialog}; + guard.mapVariants.push_back(variant); + MonsterDefinition altar = Monster(205, "Wolf Altar1"); + Data::Monsters::MonsterTalkBlock contract; + contract.kind = Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract; + contract.altarTexts = "gone"; + altar.setup.onTalk = {contract}; + MonsterDefinition named = Monster(206, "Wolf Altar2"); + contract.altarTexts = "crywolfAltar"; + named.setup.onTalk = {contract}; + Data::Monsters::MonsterTalkTexts texts; + texts["crywolfAltar"]; + const std::array monsters = {&guard, &altar, &named}; + std::vector issues; + + Data::Monsters::ValidateMonsterTalkSteps(monsters, texts, "Monsters.json", issues); + + REQUIRE(issues.size() == 2); + CHECK(issues[0].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[0].field == "monster 407 mapVariants[0].onTalk[0].dialog.ok.if.is"); + CHECK(issues[1].severity == ItemDataIssueSeverity::Error); + CHECK(issues[1].field == "monster 205 onTalk[0].crywolfAltarContract"); +} + +// The altar contract's code counts from the altars' models: on another model +// the step only talks, a warning. +TEST_CASE("Monster data: an altar contract step on a model that is no altar is a warning [data][monsters]") +{ + Data::Monsters::MonsterTalkBlock contract; + contract.kind = Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract; + MonsterDefinition altar = Monster(205, "Altar"); + altar.model = "MODEL_CRYWOLF_ALTAR1"; + altar.setup.onTalk = {contract}; + MonsterDefinition smith = Monster(251, "Hanzo"); + smith.model = "MODEL_SMITH"; + smith.setup.onTalk = {contract}; + MonsterDefinition onMap = Monster(206, "Altar 2"); + onMap.model = "MODEL_CRYWOLF_ALTAR2"; + Data::Monsters::MonsterMapVariant variant; + variant.maps = {Data::Maps::MapRef{34, 0}}; + variant.model = "MODEL_SMITH"; + variant.setup.onTalk = {contract}; + onMap.mapVariants.push_back(variant); + const std::array monsters = {&altar, &smith, &onMap}; + std::vector issues; + + Data::Monsters::ValidateMonsterSetups(monsters, "Monsters.json", issues); + + REQUIRE(issues.size() == 2); + CHECK(issues[0].severity == ItemDataIssueSeverity::Warning); + CHECK(issues[0].field == "monster 251 onTalk"); + CHECK(issues[0].message.find("MODEL_SMITH") != std::string::npos); + CHECK(issues[1].field == "monster 206 mapVariants[0].onTalk"); +} diff --git a/tests/data/test_monster_setup_json.cpp b/tests/data/test_monster_setup_json.cpp index 75a24ca20a..fd3a3cfc14 100644 --- a/tests/data/test_monster_setup_json.cpp +++ b/tests/data/test_monster_setup_json.cpp @@ -285,6 +285,128 @@ TEST_CASE("Monster data: state variants and the height fields (Q-MN36c) [data][m // The hooks hand over to the maps' own code with the values it fixed // (Q-MN36d); a joint carries its key like an effect. +// An NPC's talk (MN5, D66): whether it talks, its minimum level and its +// message, its repair shop and its talk's steps, with texts by language. +TEST_CASE("Monster data: reads and writes the talk fields of an NPC (D66) [data][monsters]") +{ + const GroupsResult read = ReadGroups(File(R"({ + "name": "Gatekeeper", "kind": "npc", + "talks": true, "repairShop": true, + "talkLevel": {"level": 10, "message": {"en": "Only level %d or above", "de": "Erst ab Stufe %d"}}, + "onTalk": [ + {"send": "talk"}, + {"dialog": {"title": "Gatekeeper", "lines": ["Who are you?", {"en": "Line two", "de": "Zeile zwei"}], + "ok": {"request": "enterRefuge", "if": {"quest": "thirdChangeUp2", "is": ["ongoing", "done"]}, + "zen": 3000000, "failure": "You are short of Zen."}}}, + {"send": "petInfo"}, + {"message": "Health has been recovered."}, + {"questWindow": {"noQuestTitleFor": ["darkLord", "magicGladiator"]}} + ], + "numbers": [{"number": 408}] })")); + + CHECK_MESSAGE(read.issues.empty(), Messages(read.issues)); + REQUIRE(read.monsters.size() == 1); + const MonsterSetup& setup = read.monsters[0].numbers[0].setup; + CHECK(setup.talks == true); + CHECK(setup.repairShop == true); + REQUIRE(setup.talkLevel.has_value()); + CHECK(setup.talkLevel->level == 10); + CHECK(setup.talkLevel->message.Get("de") == "Erst ab Stufe %d"); + using Kind = Data::Monsters::MonsterTalkBlock::Kind; + REQUIRE(setup.onTalk.size() == 5); + CHECK(setup.onTalk[0].kind == Kind::SendTalk); + CHECK(setup.onTalk[1].kind == Kind::Dialog); + CHECK(setup.onTalk[1].dialog.title.GetNeutral() == "Gatekeeper"); + REQUIRE(setup.onTalk[1].dialog.lines.size() == 2); + REQUIRE(setup.onTalk[1].dialog.ok.has_value()); + CHECK(setup.onTalk[1].dialog.ok->request == "enterRefuge"); + CHECK(setup.onTalk[1].dialog.ok->quest == "thirdChangeUp2"); + CHECK(setup.onTalk[1].dialog.ok->questStates == std::vector{"ongoing", "done"}); + CHECK(setup.onTalk[1].dialog.ok->zen == 3000000); + CHECK(setup.onTalk[2].kind == Kind::SendPetInfo); + CHECK(setup.onTalk[3].kind == Kind::Message); + CHECK(setup.onTalk[4].kind == Kind::QuestWindow); + CHECK(setup.onTalk[4].noQuestTitleFor.size() == 2); + + const std::string text = Data::Monsters::WriteMonstersJson(std::span(read.monsters)); + INFO(text); + const GroupsResult again = ReadGroups(text); + CHECK_MESSAGE(again.issues.empty(), Messages(again.issues)); + CHECK(again.monsters == read.monsters); + + // Names the client does not know are fatal. + const GroupsResult wrong = ReadGroups(File(R"({ + "name": "X", "kind": "npc", + "onTalk": [{"send": "shop"}, {"dialog": {"lines": [], "ok": {"request": "fly"}}}, + {"questWindow": {"noQuestTitleFor": ["bard"]}}, {"wave": 1}], + "numbers": [{"number": 538}] })")); + CHECK(Has(wrong.issues, ItemDataIssueSeverity::Fatal, "monster 538 onTalk[0].send")); + CHECK(Has(wrong.issues, ItemDataIssueSeverity::Fatal, "monster 538 onTalk[1].dialog.ok.request")); + CHECK(Has(wrong.issues, ItemDataIssueSeverity::Fatal, "monster 538 onTalk[2].questWindow.noQuestTitleFor[0]")); + CHECK(Has(wrong.issues, ItemDataIssueSeverity::Fatal, "monster 538 onTalk[3]")); + + // A quest without states: the OK never sends, a warning. + const GroupsResult noStates = ReadGroups(File(R"({ + "name": "X", "kind": "npc", + "onTalk": [{"dialog": {"lines": [], "ok": {"request": "enterRefuge", "if": {"quest": "changeUp1", "is": []}}}}], + "numbers": [{"number": 538}] })")); + CHECK(Has(noStates.issues, ItemDataIssueSeverity::Warning, "monster 538 onTalk[0].dialog.ok.if.is")); +} + +// A Crywolf altar's contract step names its six texts in the list's +// talkTexts, which the five altars share (Q-MN46); a name the list does not +// have and a set missing a text are fatal. +TEST_CASE("Monster data: a Crywolf altar's contract step and its shared texts [data][monsters]") +{ + const std::string talkTexts = R"("talkTexts": {"crywolfAltar": {"canContract": "Left: %d", "proceed": "Go?", + "onMount": "No mount.", "needGuardian": "Guard!", "destroyed": {"en": "Gone.", "de": "Weg."}, + "ongoing": "Busy."}},)"; + const std::string altar = R"({"name": "Wolf Altar1", "kind": "npc", + "onTalk": [{"crywolfAltarContract": "crywolfAltar"}], "numbers": [{"number": 205}]})"; + const std::string file = R"({ "formatVersion": 2, )" + talkTexts + R"( "monsters": [ )" + altar + " ] }"; + std::vector groups; + Data::Monsters::UnknownMonsterLook unknown; + Data::Monsters::MonsterTalkTexts texts; + std::vector issues; + Data::Monsters::ReadMonstersJson(file, "Monsters.json", groups, unknown, texts, issues); + CHECK_MESSAGE(issues.empty(), Messages(issues)); + REQUIRE(groups.size() == 1); + const MonsterSetup& setup = groups[0].numbers[0].setup; + REQUIRE(setup.onTalk.size() == 1); + CHECK(setup.onTalk[0].kind == Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract); + CHECK(setup.onTalk[0].altarTexts == "crywolfAltar"); + REQUIRE(texts.contains("crywolfAltar")); + CHECK(texts.at("crywolfAltar").canContract.GetNeutral() == "Left: %d"); + CHECK(texts.at("crywolfAltar").destroyed.Get("de") == "Weg."); + + // Written and read again, the same. + const std::string written = Data::Monsters::WriteMonstersJson(groups, unknown, texts); + INFO(written); + std::vector again; + Data::Monsters::MonsterTalkTexts againTexts; + std::vector againIssues; + Data::Monsters::ReadMonstersJson(written, "Monsters.json", again, unknown, againTexts, againIssues); + CHECK_MESSAGE(againIssues.empty(), Messages(againIssues)); + CHECK(again == groups); + CHECK(againTexts == texts); + + // A name the list does not have. + const GroupsResult unnamed = ReadGroups(File(altar)); + CHECK(Has(unnamed.issues, ItemDataIssueSeverity::Fatal, "monster 205 onTalk[0].crywolfAltarContract")); + // A set without one of its texts. + const GroupsResult missing = ReadGroups(R"({ "formatVersion": 2, + "talkTexts": {"crywolfAltar": {"canContract": "Left: %d"}}, "monsters": [ )" + + altar + " ] }"); + CHECK(Has(missing.issues, ItemDataIssueSeverity::Fatal, "talkTexts.crywolfAltar.ongoing")); + // That one problem only: the step still finds its set. + CHECK_FALSE(Has(missing.issues, ItemDataIssueSeverity::Fatal, "monster 205 onTalk[0].crywolfAltarContract")); + // The same for a set that is no object. + const GroupsResult wrongSet = ReadGroups(R"({ "formatVersion": 2, "talkTexts": {"crywolfAltar": "oops"}, + "monsters": [ )" + altar + " ] }"); + CHECK(Has(wrongSet.issues, ItemDataIssueSeverity::Fatal, "talkTexts.crywolfAltar")); + CHECK_FALSE(Has(wrongSet.issues, ItemDataIssueSeverity::Fatal, "monster 205 onTalk[0].crywolfAltarContract")); +} + TEST_CASE("Monster data: spawn hooks with their values, and a joint's key [data][monsters]") { const GroupsResult read = ReadGroups(File(R"({ @@ -602,7 +724,7 @@ TEST_CASE("Monster data: the unknown monsters' look [data][monsters]") CHECK_FALSE(look.forMissingFile); // Written and read back, it is the same. - const std::string text = Data::Monsters::WriteMonstersJson(std::span(groups), look); + const std::string text = Data::Monsters::WriteMonstersJson(std::span(groups), look, {}); INFO(text); std::vector again; Data::Monsters::UnknownMonsterLook readBack; diff --git a/tests/data/test_monster_tool_windows.cpp b/tests/data/test_monster_tool_windows.cpp index 1de9662526..e016cd76c7 100644 --- a/tests/data/test_monster_tool_windows.cpp +++ b/tests/data/test_monster_tool_windows.cpp @@ -35,6 +35,7 @@ #include "UI/MonsterTools/MuMonsterPreviewUI.h" #include "UI/MonsterTools/MonsterSetupFields.h" #include "UI/MonsterTools/MonsterSetupLists.h" +#include "UI/MonsterTools/TalkStepStates.h" #include "UI/MonsterTools/MuMonsterSetupUI.h" #include "UI/MonsterTools/MuMonsterEquipmentUI.h" #include "UI/Common/MapPicker.h" @@ -1663,6 +1664,173 @@ TEST_CASE("Monster Setup: a player-shaped monster's box starts as the player's [ g_MonsterEdits.GetHistory().Clear(); } +// The NPC rules section: an NPC's talk steps, added, moved with their open +// state and removed, and its repair shop box (D66). +TEST_CASE("Monster Setup: an NPC's talk steps are added, moved and removed [editor][monsters]") +{ + MonsterDefinition gatekeeper = Monster(408, "Gatekeeper"); + gatekeeper.kind = Data::Monsters::MonsterKind::Npc; + gatekeeper.setup.onTalk.resize(2); + gatekeeper.setup.onTalk[1].kind = Data::Monsters::MonsterTalkBlock::Kind::SendPetInfo; + const std::array monsters = {gatekeeper}; + g_MonsterDatabase.Build(monsters, "en"); + g_MonsterEdits.GetHistory().Clear(); + TestImGuiContext context; + g_MonsterSelection.Select(408); + const auto render = [] + { + ImGui::NewFrame(); + bool open = true; + g_MuMonsterSetupUI.Render(&open); + ImGui::Render(); + CHECK(ImGui::GetCurrentContext()->ErrorCountCurrentFrame == 0); + }; + const auto click = [&](ImGuiID id) + { + ImGui::ActivateItemByID(id); + render(); + render(); + }; + const auto stored = [] { return *g_MonsterDatabase.Find(408); }; + render(); + const ImGuiWindow* window = ImGui::FindWindowByName(WindowIds::MonsterSetup); + REQUIRE(window != nullptr); + click(ImGui::GetIDWithSeed(I18N::Editor::NPCRules, nullptr, ImGui::GetIDWithSeed(408, window->ID))); + + click(SetupItemId(408, "onTalk", I18N::Editor::AddAStep)); + REQUIRE(stored().setup.onTalk.size() == 3); + CHECK(stored().setup.onTalk[2].kind == Data::Monsters::MonsterTalkBlock::Kind::SendTalk); + + const ImGuiID list = ImGui::GetIDWithSeed("onTalk", nullptr, ImGui::GetIDWithSeed(408, window->ID)); + ImGuiStorage& storage = ImGui::FindWindowByName(WindowIds::MonsterSetup)->StateStorage; + // A row's step is keyed by the step's id, which the window keeps per row. + const auto rowOf = [&](int index) + { + const ImGuiID row = ImGui::GetIDWithSeed(index, ImGui::GetIDWithSeed("##Steps", nullptr, list)); + return ImGui::GetIDWithSeed(storage.GetInt(ImGui::GetIDWithSeed("id", nullptr, row), 0), list); + }; + const auto isOpen = [&](int index) + { return storage.GetInt(ImGui::GetIDWithSeed("##Step", nullptr, rowOf(index)), 0) != 0; }; + // The second step, opened and moved up, stays open as the first. + storage.SetInt(ImGui::GetIDWithSeed("##Step", nullptr, rowOf(1)), 1); + // An open step's buttons are under its tree node. + click(ImGui::GetIDWithSeed(I18N::Editor::MoveUp, nullptr, ImGui::GetIDWithSeed("##Step", nullptr, rowOf(1)))); + CHECK(stored().setup.onTalk[0].kind == Data::Monsters::MonsterTalkBlock::Kind::SendPetInfo); + CHECK(isOpen(0)); + CHECK_FALSE(isOpen(1)); + // Undone elsewhere, the open step goes back down with its move. + REQUIRE(g_MonsterEdits.Undo()); + render(); + CHECK(stored().setup.onTalk[1].kind == Data::Monsters::MonsterTalkBlock::Kind::SendPetInfo); + CHECK_FALSE(isOpen(0)); + CHECK(isOpen(1)); + + // The third step, open, stays open a row up when the first is removed. + storage.SetInt(ImGui::GetIDWithSeed("##Step", nullptr, rowOf(1)), 0); + storage.SetInt(ImGui::GetIDWithSeed("##Step", nullptr, rowOf(2)), 1); + click(ImGui::GetIDWithSeed("x", nullptr, rowOf(0))); + CHECK(stored().setup.onTalk.size() == 2); + CHECK_FALSE(isOpen(0)); + CHECK(isOpen(1)); + // A text changed elsewhere (Monster Translations) keeps it open. + MonsterDefinition translated = stored(); + translated.setup.onTalk[1].message.Set("de", "Hallo"); + g_MonsterDatabase.Set(translated); + render(); + CHECK(isOpen(1)); + + click(SetupItemId(408, "repairShop", I18N::Editor::Set)); + CHECK(stored().setup.repairShop == true); + REQUIRE(g_MonsterEdits.Undo()); + CHECK_FALSE(stored().setup.repairShop.has_value()); + g_MonsterSelection.Select(MonsterSelection::NoMonster); + g_MonsterEdits.GetHistory().Clear(); +} + +// A talk step's id from the rows before (a change made elsewhere): the same +// step in its row, a moved one, one changed in its row, one shown no more, two +// equal ones, and a new one. +TEST_CASE("Monster Setup: talk steps find their ids again after a change elsewhere [editor][monsters]") +{ + using MuEditor::Monsters::SetupLists::MatchStepIds; + using MuEditor::Monsters::SetupLists::StepRow; + int next = 100; + const auto newId = [&] { return next++; }; + const std::vector abc = {{10, 1}, {20, 2}, {30, 3}}; + + CHECK(MatchStepIds(abc, std::vector{10, 20, 30}, newId) == std::vector{1, 2, 3}); + // Moved: B up. + CHECK(MatchStepIds(abc, std::vector{20, 10, 30}, newId) == std::vector{2, 1, 3}); + // A's text changed in its row. + CHECK(MatchStepIds(abc, std::vector{11, 20, 30}, newId) == std::vector{1, 2, 3}); + // A gone: B and C keep theirs, though C's row is past the new length. + CHECK(MatchStepIds(abc, std::vector{20, 30}, newId) == std::vector{2, 3}); + // Two equal steps keep one id each. + const std::vector twins = {{10, 1}, {10, 2}}; + CHECK(MatchStepIds(twins, std::vector{10, 10}, newId) == std::vector{1, 2}); + // A new step. + CHECK(MatchStepIds(std::vector{}, std::vector{10}, newId) == std::vector{100}); +} + +// Two steps of the same kind: an open one moved up and the move undone goes +// back down still open, found again by its texts. +TEST_CASE("Monster Setup: an open talk step follows its move's undo among steps of its kind [editor][monsters]") +{ + MonsterDefinition santa = Monster(470, "Little Santa"); + santa.kind = Data::Monsters::MonsterKind::Npc; + santa.setup.onTalk.resize(2); + for (int index = 0; index < 2; ++index) + { + santa.setup.onTalk[index].kind = Data::Monsters::MonsterTalkBlock::Kind::Message; + santa.setup.onTalk[index].message.Set("en", index == 0 ? "First" : "Second"); + } + const std::array monsters = {santa}; + g_MonsterDatabase.Build(monsters, "en"); + g_MonsterEdits.GetHistory().Clear(); + TestImGuiContext context; + g_MonsterSelection.Select(470); + const auto render = [] + { + ImGui::NewFrame(); + bool open = true; + g_MuMonsterSetupUI.Render(&open); + ImGui::Render(); + CHECK(ImGui::GetCurrentContext()->ErrorCountCurrentFrame == 0); + }; + const auto click = [&](ImGuiID id) + { + ImGui::ActivateItemByID(id); + render(); + render(); + }; + const auto stored = [] { return *g_MonsterDatabase.Find(470); }; + render(); + const ImGuiWindow* window = ImGui::FindWindowByName(WindowIds::MonsterSetup); + REQUIRE(window != nullptr); + click(ImGui::GetIDWithSeed(I18N::Editor::NPCRules, nullptr, ImGui::GetIDWithSeed(470, window->ID))); + const ImGuiID list = ImGui::GetIDWithSeed("onTalk", nullptr, ImGui::GetIDWithSeed(470, window->ID)); + ImGuiStorage& storage = ImGui::FindWindowByName(WindowIds::MonsterSetup)->StateStorage; + const auto stepOf = [&](int index) + { + const ImGuiID row = ImGui::GetIDWithSeed(index, ImGui::GetIDWithSeed("##Steps", nullptr, list)); + return ImGui::GetIDWithSeed(storage.GetInt(ImGui::GetIDWithSeed("id", nullptr, row), 0), list); + }; + const auto isOpen = [&](int index) + { return storage.GetInt(ImGui::GetIDWithSeed("##Step", nullptr, stepOf(index)), 0) != 0; }; + + storage.SetInt(ImGui::GetIDWithSeed("##Step", nullptr, stepOf(1)), 1); + click(ImGui::GetIDWithSeed(I18N::Editor::MoveUp, nullptr, ImGui::GetIDWithSeed("##Step", nullptr, stepOf(1)))); + CHECK(stored().setup.onTalk[0].message.GetNeutral() == "Second"); + CHECK(isOpen(0)); + REQUIRE(g_MonsterEdits.Undo()); + render(); + CHECK(stored().setup.onTalk[1].message.GetNeutral() == "Second"); + CHECK(isOpen(1)); + CHECK_FALSE(isOpen(0)); + g_MonsterSelection.Select(MonsterSelection::NoMonster); + g_MonsterEdits.GetHistory().Clear(); +} + // A facing-by-tile row switched to the other axis has that axis only, with // its tile, and the field shows that axis' tile. // A typed value the reader would refuse is kept to its range: a class step @@ -2042,4 +2210,56 @@ TEST_CASE("Monster Setup: the unknown monsters' look is edited, undone and previ g_MonsterModelDatabase.Build({}); } +// The shared talk texts in Monster Setup: x removes a set no step names, as +// an undo step; a set a step names stays. +TEST_CASE("Monster Setup: a shared talk text set is removed and undone, a named one stays [editor][monsters]") +{ + MonsterDefinition altar = Monster(205, "Wolf Altar1"); + Data::Monsters::MonsterTalkBlock contract; + contract.kind = Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract; + contract.altarTexts = "crywolfAltar"; + altar.setup.onTalk = {contract}; + const std::array monsters = {altar}; + g_MonsterDatabase.Build(monsters, "en"); + Data::Monsters::MonsterTalkTexts texts; + texts["crywolfAltar"].canContract.Set("en", "Left: %d"); + texts["spare"].canContract.Set("en", "Spare"); + g_MonsterDatabase.SetTalkTexts(texts); + g_MonsterEdits.GetHistory().Clear(); + TestImGuiContext context; + g_MonsterSelection.Select(205); + const auto render = [] + { + ImGui::NewFrame(); + bool open = true; + g_MuMonsterSetupUI.Render(&open); + ImGui::Render(); + CHECK(ImGui::GetCurrentContext()->ErrorCountCurrentFrame == 0); + }; + const auto click = [&](ImGuiID id) + { + ImGui::ActivateItemByID(id); + render(); + render(); + }; + render(); + const ImGuiWindow* window = ImGui::FindWindowByName(WindowIds::MonsterSetup); + REQUIRE(window != nullptr); + click(ImGui::GetIDWithSeed(I18N::Editor::SharedTalkTexts, nullptr, window->ID)); + const ImGuiID section = ImGui::GetIDWithSeed("SharedTalkTexts", nullptr, window->ID); + const auto removeOf = [&](const char* name) + { return ImGui::GetIDWithSeed("x", nullptr, ImGui::GetIDWithSeed(name, nullptr, section)); }; + + click(removeOf("crywolfAltar")); + CHECK(g_MonsterDatabase.GetTalkTexts().contains("crywolfAltar")); + click(removeOf("spare")); + CHECK_FALSE(g_MonsterDatabase.GetTalkTexts().contains("spare")); + REQUIRE(g_MonsterEdits.Undo()); + CHECK(g_MonsterDatabase.GetTalkTexts() == texts); + + g_MonsterSelection.Select(MonsterSelection::NoMonster); + g_MonsterDatabase.SetTalkTexts({}); + g_MonsterEdits.GetHistory().Clear(); +} + #endif // _EDITOR diff --git a/tests/data/test_monster_translations_window.cpp b/tests/data/test_monster_translations_window.cpp index 75e0e16a0e..3c32b58ee1 100644 --- a/tests/data/test_monster_translations_window.cpp +++ b/tests/data/test_monster_translations_window.cpp @@ -18,11 +18,13 @@ #include "UI/Common/UndoTarget.h" #include "UI/MonsterTools/MonsterEdits.h" #include "UI/MonsterTools/MonsterNpcNameFiles.h" +#include "UI/MonsterTools/MonsterTalkTextRows.h" #include "UI/MonsterTools/MonsterToolWindow.h" #include "UI/MonsterTools/MuMonsterTranslationsUI.h" #include "imgui.h" #include "imgui_internal.h" +#include #include #include #include @@ -50,6 +52,7 @@ MonsterDefinition Monster(int number, const char* names) void StartWith(const std::vector& monsters) { g_MonsterDatabase.Build(monsters, "en"); + g_MonsterDatabase.SetTalkTexts({}); g_MonsterEdits.OnDataReplaced(); g_MonsterEdits.GetHistory().MarkSaved(); } @@ -99,7 +102,10 @@ int ColumnOf(std::string_view locale) // The name field of a monster in a language's column. ImGuiID NameCellId(int number, std::string_view locale) { - const ImGuiID table = ImGui::GetIDWithSeed(TableId, nullptr, TranslationsWindow()->ID); + // The names are in the window's Names tab. + const ImGuiID tabBar = ImGui::GetIDWithSeed("##Texts", nullptr, TranslationsWindow()->ID); + const ImGuiID tab = ImGui::GetIDWithSeed(I18N::Editor::Names, nullptr, tabBar); + const ImGuiID table = ImGui::GetIDWithSeed(TableId, nullptr, tab); const ImGuiID row = ImGui::GetIDWithSeed(number, table); return ImGui::GetIDWithSeed("##Name", nullptr, ImGui::GetIDWithSeed(ColumnOf(locale), row)); } @@ -163,6 +169,92 @@ TEST_CASE("Monster Translations: a name edited in the grid is an undo step [edit CHECK_FALSE(NamesOf(2).HasOwn("de")); } +// The talk texts of the NPCs (Q-MN47): a row per text, its missing +// languages listed, an edit stored as an undo step. +TEST_CASE("Monster Translations: an NPC's talk texts are rows of their own [editor][monsters]") +{ + MonsterDefinition gatekeeper = Monster(408, "Gatekeeper"); + Data::Monsters::MonsterTalkBlock dialog; + dialog.kind = Data::Monsters::MonsterTalkBlock::Kind::Dialog; + dialog.dialog.title.Set("en", "Gatekeeper"); + dialog.dialog.lines.resize(2); + dialog.dialog.lines[0].Set("en", "Who are you?"); + dialog.dialog.lines[0].Set("de", "Wer bist du?"); + dialog.dialog.lines[1].Set("en", "Go on."); + gatekeeper.setup.onTalk = {dialog}; + StartWith({Monster(2, "Budge Dragon"), gatekeeper}); + + MuEditor::Monsters::MonsterTalkTextRows rows; + REQUIRE(rows.List("", "").size() == 3); + char id[96]; + rows.FormatId(1, id, sizeof(id)); + CHECK(std::string(id) == "408 onTalk[0].dialog.lines[0]"); + CHECK(rows.FindNames(1)->GetOwn("de") == "Wer bist du?"); + // Two texts miss their German. + CHECK(rows.List("", "de").size() == 2); + CHECK(rows.CountMissing(std::array{"de"})[0] == 2); + + rows.CommitName(2, "de", "Geh weiter."); + CHECK(g_MonsterDatabase.Find(408)->setup.onTalk[0].dialog.lines[1].GetOwn("de") == "Geh weiter."); + MuEditor::Monsters::ToolWindow::GetUndoTarget().Undo(); + CHECK_FALSE(g_MonsterDatabase.Find(408)->setup.onTalk[0].dialog.lines[1].HasOwn("de")); +} + +// The shared talk texts (the list's talkTexts) are rows of their own, first; +// an edit is an undo step. +TEST_CASE("Monster Translations: the shared talk texts are rows [editor][monsters]") +{ + StartWith({Monster(2, "Budge Dragon")}); + Data::Monsters::MonsterTalkTexts texts; + for (Data::LocalizedString* text : Data::Monsters::AltarContractTexts(texts["crywolfAltar"])) + text->Set("en", "Text"); + g_MonsterDatabase.SetTalkTexts(texts); + + MuEditor::Monsters::MonsterTalkTextRows rows; + REQUIRE(rows.List("", "").size() == 6); + char id[96]; + rows.FormatId(0, id, sizeof(id)); + CHECK(std::string(id) == "talkTexts.crywolfAltar.canContract"); + rows.FormatId(5, id, sizeof(id)); + CHECK(std::string(id) == "talkTexts.crywolfAltar.ongoing"); + + rows.CommitName(1, "de", "Fortfahren?"); + CHECK(g_MonsterDatabase.GetTalkTexts().at("crywolfAltar").proceed.GetOwn("de") == "Fortfahren?"); + MuEditor::Monsters::ToolWindow::GetUndoTarget().Undo(); + CHECK_FALSE(g_MonsterDatabase.GetTalkTexts().at("crywolfAltar").proceed.HasOwn("de")); + g_MonsterDatabase.SetTalkTexts({}); +} + +// The texts of a map variant's talk and of a state variant's are rows too. +TEST_CASE("Monster Translations: the talk texts of map and state variants are rows [editor][monsters]") +{ + Data::Monsters::MonsterTalkBlock message; + message.kind = Data::Monsters::MonsterTalkBlock::Kind::Message; + message.message.Set("en", "On Crywolf."); + MonsterDefinition npc = Monster(408, "Gatekeeper"); + Data::Monsters::MonsterMapVariant variant; + variant.maps = {Data::Maps::MapRef{34, 0}}; + variant.setup.onTalk = {message}; + npc.mapVariants.push_back(variant); + Data::Monsters::MonsterStateVariant state; + state.state = "crywolfReady"; + state.setup.onTalk = {message}; + state.setup.onTalk[0].message.Set("en", "Ready."); + npc.setup.stateVariants.push_back(state); + StartWith({npc}); + + MuEditor::Monsters::MonsterTalkTextRows rows; + REQUIRE(rows.List("", "").size() == 2); + char id[96]; + rows.FormatId(0, id, sizeof(id)); + CHECK(std::string(id) == "408 stateVariants[0].onTalk[0].message"); + rows.FormatId(1, id, sizeof(id)); + CHECK(std::string(id) == "408 mapVariants[0].onTalk[0].message"); + + rows.CommitName(1, "de", "Auf Crywolf."); + CHECK(g_MonsterDatabase.Find(408)->mapVariants[0].setup.onTalk[0].message.GetOwn("de") == "Auf Crywolf."); +} + TEST_CASE("Monster Translations: a name with the separator is not stored [editor][monsters]") { StartWith({Monster(2, "Budge Dragon")}); diff --git a/tests/monsters/CMakeLists.txt b/tests/monsters/CMakeLists.txt index 121bfda27d..b2d02cc27b 100644 --- a/tests/monsters/CMakeLists.txt +++ b/tests/monsters/CMakeLists.txt @@ -19,6 +19,7 @@ mu_add_test( test_monster_creation_models.cpp test_monster_creation_recorder.cpp test_monster_setup.cpp + test_npc_talk.cpp ${CMAKE_SOURCE_DIR}/src/source/App/Platform/Windows/Winmain.cpp LINK_LIBS MuClient diff --git a/tests/monsters/test_monster_setup.cpp b/tests/monsters/test_monster_setup.cpp index 2302333849..19c170915b 100644 --- a/tests/monsters/test_monster_setup.cpp +++ b/tests/monsters/test_monster_setup.cpp @@ -11,6 +11,7 @@ #include "doctest.h" #include "Audio/DSPlaySound.h" +#include "Data/DataHandler/MonsterData/MonsterJsonStorage.h" #include "Data/DataHandler/MonsterData/MonsterModelLoader.h" #include "Data/GameData/MonsterData/MonsterAnimations.h" #include "Data/GameData/MonsterData/MonsterDatabase.h" @@ -1034,6 +1035,55 @@ TEST_CASE("Monster setup: Respawn keeps the buffs the server gave (D55) [monster CHECK_FALSE(object.m_BuffMap.isBuff(eDeBuff_Poison)); } +// The Crywolf hero contract rule of 0x07 is the entries' crywolfContract: a +// monster given it runs the rule too. +TEST_CASE("Monster setup: a monster in the Crywolf hero contract loses its states to a buff sent again " + "[monsters][creation]") +{ + using MonsterRecorder::CreationScope; + CreationScope scope(0); + const MonsterEntryScope kept(MONSTER_BUDGE_DRAGON); + Data::Monsters::MonsterDefinition changed = kept.Original(); + changed.setup.crywolfContract = true; + g_MonsterDatabase.Set(changed); + MonsterRecorder::PrepareCreationSlot(CreationSlot::Clean); + CHARACTER* budge = scope.Create(MONSTER_BUDGE_DRAGON); + REQUIRE(budge != nullptr); + g_CharacterRegisterBuff((&budge->Object), eBuff_CrywolfAltarEnable); + CreationScope::SendBuffState(*budge, eBuff_CrywolfAltarEnable, true); + CHECK_FALSE(budge->Object.m_BuffMap.isBuff(eBuff_CrywolfAltarEnable)); +} + +// The shipped entries with crywolfContract are those the old code named by +// model: the five altars, MODEL_SMITH, MODEL_NPC_SERBIS, MODEL_MERCHANT_MAN, +// MODEL_STORAGE and MODEL_NPC_BREEDER, on the entry and its map variants. +TEST_CASE("Monster setup: the shipped crywolfContract entries are the old code's models [monsters][creation]") +{ + constexpr std::array OldModels{ + "MODEL_CRYWOLF_ALTAR1", "MODEL_CRYWOLF_ALTAR2", "MODEL_CRYWOLF_ALTAR3", "MODEL_CRYWOLF_ALTAR4", + "MODEL_CRYWOLF_ALTAR5", "MODEL_SMITH", "MODEL_NPC_SERBIS", "MODEL_MERCHANT_MAN", + "MODEL_STORAGE", "MODEL_NPC_BREEDER"}; + const auto oldRule = [&](const std::string& model) + { return std::ranges::find(OldModels, model) != OldModels.end(); }; + const std::vector monsters = + Data::Monsters::LoadMonstersFile(MonsterRecorder::ClientFolder() / "Data" / "Monsters" / "Monsters.json") + .monsters; + REQUIRE(monsters.size() > 500); + int contracts = 0; + for (const Data::Monsters::MonsterDefinition& monster : monsters) + { + INFO("monster " << monster.number); + const bool own = monster.setup.crywolfContract.value_or(false); + CHECK(own == oldRule(monster.model)); + contracts += own ? 1 : 0; + for (const Data::Monsters::MonsterMapVariant& variant : monster.mapVariants) + { + CHECK(variant.setup.crywolfContract.value_or(own) == oldRule(variant.model.value_or(monster.model))); + } + } + CHECK(contracts == 13); +} + // Respawn makes a monster with a tile offset (Elpis) on the server's tile, as // 0x13 keeps it, so the body stands at the offset once and the tile stays // the server's; one that walked comes again on the tile it walked to. diff --git a/tests/monsters/test_npc_talk.cpp b/tests/monsters/test_npc_talk.cpp new file mode 100644 index 0000000000..ef2bf4126b --- /dev/null +++ b/tests/monsters/test_npc_talk.cpp @@ -0,0 +1,393 @@ +#include "stdafx.h" + +// Talking to an NPC (MOVEMENT_TALK, MN5): the shipped monster data's talk +// fields and the map rules that stay code, recorded through a TalkWorld that +// stands in for the game (D50). + +#include "doctest.h" + +#include "MonsterModelRecorder.h" + +#include "Core/Globals/_define.h" +#include "Core/Globals/_enum.h" +#include "Data/DataHandler/MonsterData/MonsterJsonStorage.h" +#include "Data/GameData/MonsterData/MonsterDatabase.h" +#include "Data/GameData/MonsterData/MonsterSetup.h" +#include "Engine/Object/w_CharacterInfo.h" +#include "GameLogic/Monsters/MonsterSetupApply.h" +#include "GameLogic/NPCs/NpcTalk.h" +#include "I18N/All.h" +#include "UI/Dialogs/GenericConfirmDialog.h" +#include "World/GameMaps/MonsterSpawnWorld.h" + +#include +#include +#include +#include +#include + +namespace +{ +using GameLogic::Npcs::Talk; +using GameLogic::Npcs::TalkWorld; +using Dialog = mu::ui::window::GenericDialogConfig; + +constexpr int NpcKey = 77; +constexpr unsigned long RefugeZen = 3000000; +// An NPC no rule names, and a model no rule names. +constexpr int PlainNpc = MONSTER_POTION_GIRL_AMY; +constexpr int PlainModel = MODEL_SMITH; + +// A game text as it shows: "%%" was a format's "%". +std::wstring Shown(std::wstring text) +{ + for (size_t at = text.find(L"%%"); at != std::wstring::npos; at = text.find(L"%%", at + 1)) + text.erase(at, 1); + return text; +} + +// The shipped monster list, as the game loads it. +void LoadShippedMonsters() +{ + static const Data::Monsters::MonsterDataLoadResult loaded = + Data::Monsters::LoadMonstersFile(MonsterRecorder::ClientFolder() / "Data" / "Monsters" / "Monsters.json"); + REQUIRE(loaded.monsters.size() > 500); + g_MonsterDatabase.Build(loaded.monsters, "en"); + g_MonsterDatabase.SetTalkTexts(loaded.talkTexts); +} + +// The game's state a talk reads, and what it did, in order. +struct TalkRecorder +{ + TalkRecorder() + { + LoadShippedMonsters(); + } + + int level = 400; + bool crywolfElfTime = false; + bool crywolfMap = false; + bool kanturu2nd = false; + bool cursedTemple = false; + // The quests' states by quest (QUEST_3RD_CHANGE_UP_2, ...); QUEST_NONE for + // the others. + std::map questStates; + unsigned long gold = 0; + bool darkHorse = false; + bool darkRaven = false; + + std::vector log; + std::optional dialog; + GameLogic::Npcs::CrywolfAltarTexts altarTexts; + + TalkWorld World() + { + TalkWorld world; + // The setup in the states that are on, crywolfElfTime by the recorder. + world.talkSetup = [this](const CHARACTER& npc) + { + const Data::Monsters::MonsterDefinition* entry = g_MonsterDatabase.Find(npc.MonsterIndex); + REQUIRE(entry != nullptr); + GameLogic::Monsters::Setup::SpawnWorld spawnWorld = World::Monsters::GetSpawnWorld(); + spawnWorld.isStateOn = [this](std::string_view state) + { return state == "crywolfElfTime" ? crywolfElfTime : World::Monsters::GetSpawnWorld().isStateOn(state); }; + return GameLogic::Monsters::Setup::SetupNow(*entry, spawnWorld); + }; + world.locale = [] { return std::string("en"); }; + world.findTalkTexts = [](const std::string& name) -> const Data::Monsters::MonsterAltarContract* + { + const auto found = g_MonsterDatabase.GetTalkTexts().find(name); + return found != g_MonsterDatabase.GetTalkTexts().end() ? &found->second : nullptr; + }; + world.heroLevel = [this] { return level; }; + world.isCrywolfElfTime = [this] { return crywolfElfTime; }; + world.isCrywolfMap = [this] { return crywolfMap; }; + world.isKanturu2nd = [this] { return kanturu2nd; }; + world.isCursedTemple = [this] { return cursedTemple; }; + world.questState = [this](int quest) + { + const auto found = questStates.find(quest); + return found != questStates.end() ? found->second : static_cast(QUEST_NONE); + }; + world.gold = [this] { return gold; }; + world.hasDarkHorse = [this] { return darkHorse; }; + world.hasDarkRaven = [this] { return darkRaven; }; + world.sendTalk = [this](int key) { log.push_back(L"talk " + std::to_wstring(key)); }; + world.sendEnterRefuge = [this] { log.emplace_back(L"enter refuge"); }; + world.sendEnterBarracks = [this] { log.emplace_back(L"enter barracks"); }; + world.sendDarkHorseInfo = [this] { log.emplace_back(L"dark horse info"); }; + world.sendDarkRavenInfo = [this] { log.emplace_back(L"dark raven info"); }; + world.showDialog = [this](Dialog config) + { + log.push_back(L"dialog " + (config.lines.empty() ? std::wstring() : config.lines.front().text)); + dialog = std::move(config); + }; + world.systemMessage = [this](const std::wstring& text) { log.push_back(L"message " + text); }; + world.errorMessage = [this](const std::wstring& text) { log.push_back(L"error " + text); }; + world.setRepairShop = [this](bool repair) { log.push_back(repair ? L"repair shop" : L"no repair"); }; + world.setNoQuestTitleFor = [this](const std::vector& classes) + { log.push_back(L"no quest title for " + std::to_wstring(classes.size())); }; + world.beforeTalk = [this] { log.emplace_back(L"before talk"); }; + world.crywolfAltarTalk = [this](CHARACTER&, const GameLogic::Npcs::CrywolfAltarTexts& texts) + { + log.emplace_back(L"crywolf altar"); + altarTexts = texts; + }; + world.kanturu2ndTalk = [this](CHARACTER&) { log.emplace_back(L"kanturu 2nd"); }; + world.cursedTempleTalk = [this](CHARACTER&) { log.emplace_back(L"cursed temple"); }; + return world; + } +}; + +std::unique_ptr MakeNpc(int number, int model = PlainModel) +{ + auto npc = std::make_unique(); + npc->MonsterIndex = static_cast(number); + npc->Object.Type = model; + npc->Key = NpcKey; + return npc; +} + +// What every talking NPC does before its own steps. +std::vector Around(std::vector steps, bool repair = false) +{ + std::vector log{repair ? L"repair shop" : L"no repair", L"before talk", L"no quest title for 0"}; + log.insert(log.end(), steps.begin(), steps.end()); + return log; +} +} // namespace + +TEST_CASE("NPC talk: an NPC talks unless its entry says it does not [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto statue = MakeNpc(MONSTER_WOLF_STATUS); + CHECK(Talk(*statue, world)); + CHECK(recorder.log.empty()); + + const auto npc = MakeNpc(PlainNpc); + CHECK(Talk(*npc, world)); + CHECK(recorder.log == Around({L"talk 77"})); +} + +TEST_CASE("NPC talk: the Chaos Goblin talks from its level on [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto goblin = MakeNpc(MONSTER_CHAOS_GOBLIN); + recorder.level = CHAOS_MIX_LEVEL - 1; + CHECK_FALSE(Talk(*goblin, world)); + // The game's text, with the level for its "%d". + std::wstring expected = I18N::Game::OnlyLevelAboveDCanDoTheChaosCombination; + expected.replace(expected.find(L"%d"), 2, std::to_wstring(CHAOS_MIX_LEVEL)); + CHECK(recorder.log == std::vector{L"message " + expected}); + + recorder.log.clear(); + recorder.level = CHAOS_MIX_LEVEL; + CHECK(Talk(*goblin, world)); + CHECK(recorder.log == Around({L"talk 77"})); +} + +TEST_CASE("NPC talk: the repair shops show their repair buttons [monsters][talk]") +{ + for (const int number : {MONSTER_EO_THE_CRAFTSMAN, MONSTER_ZIENNA_THE_WEAPONS_MERCHANT, + MONSTER_HANZO_THE_BLACKSMITH, MONSTER_RHEA, MONSTER_WEAPONS_MERCHANT_BOLO}) + { + INFO(number); + TalkRecorder recorder; + const auto npc = MakeNpc(number); + CHECK(Talk(*npc, recorder.World())); + CHECK(recorder.log == Around({L"talk 77"}, true)); + } +} + +TEST_CASE("NPC talk: Crywolf's altars and its Werewolf Guardsman [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto altar = MakeNpc(MONSTER_WOLF_ALTAR1, MODEL_CRYWOLF_ALTAR1); + const auto guardsman = MakeNpc(MONSTER_WEREWOLF_QUARREL, MODEL_NPC_QUARREL); + + // While only elves may contract: the altar's hook, a plain talk else. + recorder.crywolfElfTime = true; + recorder.crywolfMap = true; + CHECK(Talk(*altar, world)); + CHECK(recorder.log == Around({L"crywolf altar"})); + // Its texts are its talk step's, from the game's texts. + CHECK(recorder.altarTexts.canContract == I18N::Game::ContractCanBeMadeForDTimes); + CHECK(recorder.altarTexts.ongoing == I18N::Game::ContractIsOngoingThereforeDualCompactIsNotPossible); + recorder.log.clear(); + CHECK(Talk(*guardsman, world)); + CHECK(recorder.log == Around({L"talk 77"})); + + // Otherwise an altar does nothing; the guardsman shows his dialog, then talks. + recorder.crywolfElfTime = false; + recorder.log.clear(); + CHECK(Talk(*altar, world)); + CHECK(recorder.log == Around({})); + // Away from Crywolf it talks. + recorder.crywolfMap = false; + recorder.log.clear(); + CHECK(Talk(*altar, world)); + CHECK(recorder.log == Around({L"talk 77"})); + recorder.crywolfMap = true; + recorder.log.clear(); + CHECK(Talk(*guardsman, world)); + CHECK(recorder.log == Around({std::wstring(L"dialog ") + I18N::Game::WerewolfGuardsman, L"talk 77"})); + REQUIRE(recorder.dialog.has_value()); + REQUIRE(recorder.dialog->lines.size() == 5); + CHECK(recorder.dialog->lines[0].bold); + CHECK(recorder.dialog->lines[1].text == I18N::Game::DoYouEvenKnowAboutMe); + CHECK(recorder.dialog->lines[4].text == I18N::Game::InOrderToReceiveHelpFrom); + + // His OK: the quest's step, then the zen. Another quest's step does not + // count. + recorder.log.clear(); + recorder.gold = RefugeZen; + recorder.questStates = {{QUEST_3RD_CHANGE_UP_1, QUEST_ING}, {QUEST_3RD_CHANGE_UP_3, QUEST_END}}; + recorder.dialog->onPrimary(); + CHECK(recorder.log.empty()); + recorder.questStates = {{QUEST_3RD_CHANGE_UP_2, QUEST_ING}}; + recorder.gold = RefugeZen - 1; + recorder.dialog->onPrimary(); + CHECK(recorder.log == std::vector{std::wstring(L"error ") + I18N::Game::YouAreShortOfZen}); + recorder.log.clear(); + recorder.questStates = {{QUEST_3RD_CHANGE_UP_2, QUEST_END}}; + recorder.gold = RefugeZen; + recorder.dialog->onPrimary(); + CHECK(recorder.log == std::vector{L"enter refuge"}); +} + +// The barracks' rule is the Gatekeeper's own (Q-MN46d): OpenMU places only +// him there, so other NPCs no longer show his dialog. +TEST_CASE("NPC talk: the Gatekeeper talks and shows his dialog [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto gatekeeper = MakeNpc(MONSTER_GATEKEEPER); + CHECK(Talk(*gatekeeper, world)); + CHECK(recorder.log == Around({L"talk 77", std::wstring(L"dialog ") + I18N::Game::Gatekeeper})); + REQUIRE(recorder.dialog.has_value()); + REQUIRE(recorder.dialog->lines.size() == 4); + CHECK(recorder.dialog->lines[1].text == I18N::Game::HmmWhoAreYouIMConfusedAreYouEvenApprovedOfBalgass); + + recorder.log.clear(); + recorder.questStates = {{QUEST_3RD_CHANGE_UP_3, QUEST_END}, {QUEST_3RD_CHANGE_UP_2, QUEST_ING}}; + recorder.dialog->onPrimary(); + CHECK(recorder.log.empty()); + recorder.questStates = {{QUEST_3RD_CHANGE_UP_3, QUEST_ING}}; + recorder.dialog->onPrimary(); + CHECK(recorder.log == std::vector{L"enter barracks"}); +} + +// The messages are whole (P23) and only the red and the blue one have one. +TEST_CASE("NPC talk: the Little Santas say what they gave [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto red = MakeNpc(MONSTER_LITTLE_SANTA_RED); + CHECK(Talk(*red, world)); + CHECK(recorder.log == Around({L"talk 77", L"message " + Shown(I18N::Game::HealthHasBeenRecoveredOf100)})); + CHECK(Shown(I18N::Game::HealthHasBeenRecoveredOf100).size() > 31); + + recorder.log.clear(); + const auto blue = MakeNpc(MONSTER_LITTLE_SANTA_BLUE); + CHECK(Talk(*blue, world)); + CHECK(recorder.log == Around({L"talk 77", L"message " + Shown(I18N::Game::ManaHasBeenRecoveredOf100)})); + + recorder.log.clear(); + const auto yellow = MakeNpc(MONSTER_LITTLE_SANTA_YELLOW); + CHECK(Talk(*yellow, world)); + CHECK(recorder.log == Around({L"talk 77"})); +} + +// Every NPC's talk request goes through the map's rule, Delgado's, Lugard's, +// Julia's and David's too (Q-MN46: none of them stands on those maps). +TEST_CASE("NPC talk: the map rules of Kanturu 2nd and the Illusion Temple [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto npc = MakeNpc(PlainNpc); + const auto delgado = MakeNpc(MONSTER_DELGADO); + recorder.kanturu2nd = true; + CHECK(Talk(*npc, world)); + CHECK(recorder.log == Around({L"kanturu 2nd"})); + recorder.log.clear(); + CHECK(Talk(*delgado, world)); + CHECK(recorder.log == Around({L"kanturu 2nd"})); + + recorder.kanturu2nd = false; + recorder.cursedTemple = true; + recorder.log.clear(); + CHECK(Talk(*npc, world)); + CHECK(recorder.log == Around({L"cursed temple"})); +} + +TEST_CASE("NPC talk: Marlon's quest window and the Pet Trainer's pet information [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + const auto marlon = MakeNpc(MONSTER_MARLON); + CHECK(Talk(*marlon, world)); + CHECK(recorder.log == Around({L"talk 77", L"no quest title for 3"})); + + recorder.log.clear(); + recorder.darkHorse = true; + recorder.darkRaven = true; + const auto trainer = MakeNpc(MONSTER_PET_TRAINER); + CHECK(Talk(*trainer, world)); + CHECK(recorder.log == Around({L"talk 77", L"dark horse info", L"dark raven info"})); + + // While only elves may make the altar contracts (the trainer is on + // Crywolf too), it still asks for the pets' information. + recorder.log.clear(); + recorder.crywolfElfTime = true; + recorder.crywolfMap = true; + CHECK(Talk(*trainer, world)); + CHECK(recorder.log == Around({L"talk 77", L"dark horse info", L"dark raven info"})); +} + +// The altar contract step on an NPC that is no altar only talks: in Crywolf's +// elf time, on Crywolf after it and elsewhere. +TEST_CASE("NPC talk: an altar contract step on an NPC that is no altar talks [monsters][talk]") +{ + TalkRecorder recorder; + const TalkWorld world = recorder.World(); + // The plain NPC's entry with the step, put back however the test ends. + struct EntryBack + { + Data::Monsters::MonsterDefinition entry; + ~EntryBack() + { + g_MonsterDatabase.Set(entry); + } + } const kept{*g_MonsterDatabase.Find(PlainNpc)}; + Data::Monsters::MonsterDefinition plain = kept.entry; + Data::Monsters::MonsterTalkBlock contract; + contract.kind = Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract; + contract.altarTexts = "crywolfAltar"; + plain.setup.onTalk = {contract}; + g_MonsterDatabase.Set(plain); + const auto npc = MakeNpc(PlainNpc); + + recorder.crywolfMap = true; + recorder.crywolfElfTime = true; + CHECK(Talk(*npc, world)); + CHECK(recorder.log == Around({L"talk 77"})); + recorder.crywolfElfTime = false; + recorder.log.clear(); + CHECK(Talk(*npc, world)); + CHECK(recorder.log == Around({L"talk 77"})); + recorder.crywolfMap = false; + recorder.log.clear(); + CHECK(Talk(*npc, world)); + CHECK(recorder.log == Around({L"talk 77"})); +} + +// The level message and the altar contract's count put their number in each +// "%d" the same way; another "%" stays. +TEST_CASE("NPC talk: a text's %d is the number, every one [monsters][talk]") +{ + CHECK(GameLogic::Npcs::WithNumber(L"%d left, %d in all, 100%", 3) == L"3 left, 3 in all, 100%"); + CHECK(GameLogic::Npcs::WithNumber(L"No number", 3) == L"No number"); +} From c9eed1a32e96794b1faf4765c46ce14f4c7fe952 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 12 Nov 2026 02:20:59 +0100 Subject: [PATCH 2/3] fix(tests): the non-altar talk test builds the monster list instead of calling Set MonsterDatabase::Set is editor-only, so the Linux and macOS editor-OFF builds failed on test_npc_talk.cpp. The test now builds the database from a changed copy of the shipped list and builds the shipped one back however it ends. --- tests/monsters/test_npc_talk.cpp | 34 ++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/monsters/test_npc_talk.cpp b/tests/monsters/test_npc_talk.cpp index ef2bf4126b..2f55b5160d 100644 --- a/tests/monsters/test_npc_talk.cpp +++ b/tests/monsters/test_npc_talk.cpp @@ -20,6 +20,7 @@ #include "UI/Dialogs/GenericConfirmDialog.h" #include "World/GameMaps/MonsterSpawnWorld.h" +#include #include #include #include @@ -47,13 +48,18 @@ std::wstring Shown(std::wstring text) } // The shipped monster list, as the game loads it. -void LoadShippedMonsters() +const Data::Monsters::MonsterDataLoadResult& ShippedMonsters() { static const Data::Monsters::MonsterDataLoadResult loaded = Data::Monsters::LoadMonstersFile(MonsterRecorder::ClientFolder() / "Data" / "Monsters" / "Monsters.json"); - REQUIRE(loaded.monsters.size() > 500); - g_MonsterDatabase.Build(loaded.monsters, "en"); - g_MonsterDatabase.SetTalkTexts(loaded.talkTexts); + return loaded; +} + +void LoadShippedMonsters() +{ + REQUIRE(ShippedMonsters().monsters.size() > 500); + g_MonsterDatabase.Build(ShippedMonsters().monsters, "en"); + g_MonsterDatabase.SetTalkTexts(ShippedMonsters().talkTexts); } // The game's state a talk reads, and what it did, in order. @@ -353,21 +359,23 @@ TEST_CASE("NPC talk: an altar contract step on an NPC that is no altar talks [mo { TalkRecorder recorder; const TalkWorld world = recorder.World(); - // The plain NPC's entry with the step, put back however the test ends. - struct EntryBack + // The shipped list with the step on the plain NPC, the shipped one built + // back however the test ends (Build: editor-OFF builds have no Set). + struct ShippedBack { - Data::Monsters::MonsterDefinition entry; - ~EntryBack() + ~ShippedBack() { - g_MonsterDatabase.Set(entry); + g_MonsterDatabase.Build(ShippedMonsters().monsters, "en"); } - } const kept{*g_MonsterDatabase.Find(PlainNpc)}; - Data::Monsters::MonsterDefinition plain = kept.entry; + } const back; + std::vector monsters = ShippedMonsters().monsters; + const auto plain = std::ranges::find(monsters, PlainNpc, &Data::Monsters::MonsterDefinition::number); + REQUIRE(plain != monsters.end()); Data::Monsters::MonsterTalkBlock contract; contract.kind = Data::Monsters::MonsterTalkBlock::Kind::CrywolfAltarContract; contract.altarTexts = "crywolfAltar"; - plain.setup.onTalk = {contract}; - g_MonsterDatabase.Set(plain); + plain->setup.onTalk = {contract}; + g_MonsterDatabase.Build(monsters, "en"); const auto npc = MakeNpc(PlainNpc); recorder.crywolfMap = true; From eeaabc34572d486c6b7241ea9ed275f5ce5c1d83 Mon Sep 17 00:00:00 2001 From: Mosch0512 Date: Thu, 12 Nov 2026 02:45:52 +0100 Subject: [PATCH 3/3] fix(monsters): the onTalk reader keeps no reference into a temporary iterator OnTalk bound [kind, content] to *row.items().begin(); nlohmann's iterator returns a reference to itself, so the names referred into a temporary that ended with the line. libstdc++ happened to survive it, but on macOS (libc++) every test reading the shipped monster list crashed. The step's key and value are now read from row.begin() into a string and a reference into the row. --- src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp b/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp index 01fcd85cd1..113642bd63 100644 --- a/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp +++ b/src/source/Data/GameData/MonsterData/MonsterSetupJson.cpp @@ -889,7 +889,10 @@ struct Reader continue; } MonsterTalkBlock block; - const auto& [kind, content] = *row.items().begin(); + // The step's one key and its value (the iterator's own key and value + // would end with the iterator, which is a temporary). + const std::string kind = row.begin().key(); + const OrderedJson& content = row.begin().value(); const std::string contentKey = rowKey + "." + kind; if (kind == DialogKey) {