diff --git a/docs/control-socket.md b/docs/control-socket.md index 5d192f0825..901c50aacb 100644 --- a/docs/control-socket.md +++ b/docs/control-socket.md @@ -81,6 +81,8 @@ Error codes: `bad_request`, `unknown_command`, `wrong_scene`, `busy`, | `bones` | in the world: how render's bones compare with the character animation pre-pass. `{"start":true}` clears the counts and starts checking; each answer gives `checked` (characters drawn), `players` (of those, player models), `mismatched` (any bone off by more than 0.01), `max_delta`, and the pre-pass's last `characters` count and whether it ran `parallel` | | `ui` | the open windows by name — `inventory`, `inventory_extension`, `character`, `trade`, `storage`, `storage_extension`, `mix`, `npc_shop`, `lucky_item`, `chat_input`, `party`, `command`, `my_shop`, `purchase_shop`, `npc_quest` (the quest dialog of Sebina, Marlon and Devin), and `message_box` while a dialog waits for Enter or Esc — and the window pixels of the named elements that are shown: `trade.confirm`, `trade.zen`, `inventory.repair`, `inventory.my_shop`, `npc_shop.repair`, `npc_shop.repair_all`, `my_shop.title`, `my_shop.open`, `my_shop.close`, `command.trade`, `command.purchase`, `command.party`, `npc_quest.answer.0` and on (the rows of the quest dialog's answers), `npc_quest.complete`, `npc_quest.close`, `character.stat.strength` (`agility`, `vitality`, `energy`, `command`: the character window's "+" buttons, while there are level-up points) | | `slot-pixel` (`grid`, `slot`) | the window pixel of a slot's square: `inventory` and `equipment` (the slot numbers `state` reports), `trade`, `trade_partner`, `storage`, `mix`, `npc_shop`, and the personal shops `my_shop` and `purchase_shop` (slots 204 and up, as `state` and the server number them); `not_open` while that window is closed, `bad_request` for a slot the grid does not have | +| `tooltip` (`group`, `number`, `level`, `durability`, `luck`, `skill`, `option`, `excellent`, `ancient`, `harmony`, `harmony_level`) | the lines of an item's information tooltip (an item number no item uses, or `harmony_level` without `harmony`, is a `bad_request`), as the inventory builds them for an item made from these fields (as the server would send it; left out: 0, full durability) and the hero as he is, without drawing them: `lines`, each with `text` (`"\n"` for a blank line), `color` (the `TEXT_COLOR_*` number) and `bold` | +| `tooltip-snapshot` (`out`, `group`) | writes the `tooltip` lines of every item (of `group` only, when given: one group per request keeps the frame short) at every level 0 to 15, as plain, full (luck, skill, +4 option, every excellent option), ancient (set 1) and Jewel of Harmony (option 1 at level 13) items, for the hero as he is, to the text file `out`: a line `/ +`, then one line ` [bold ]` per tooltip line (`` for a blank line). Two files compare with any diff tool, e.g. before and after a change of the tooltip code. Each request writes `out` anew, so a snapshot by groups takes one file per group. `out` is resolved like `screenshot`'s; replies `tooltips`, the count, and `out`, the absolute path written. The Dark Horse and the Dark Raven get their header line but no tooltip lines, since the pet manager draws their tooltip | | `login` (`account`, `password`, `server`) | server selection, credentials, character list | | `select-char` (`name` or `slot`) | enter the world with that character | | `logout`, `quit` | back to the character list; close the client | diff --git a/src/source/App/Control/ControlCommands.h b/src/source/App/Control/ControlCommands.h index bec260dc28..89093c476b 100644 --- a/src/source/App/Control/ControlCommands.h +++ b/src/source/App/Control/ControlCommands.h @@ -33,6 +33,12 @@ std::string EventsSince(const Request& request, std::unique_ptr& act); std::string WaitFor(const Request& request, std::unique_ptr& act); std::string Screenshot(const Request& request, std::unique_ptr& act); std::string Hotkey(const Request& request, std::unique_ptr& act); +// The lines of an item's information tooltip, built for an item the command makes (as the server sends one) and the +// hero as it is, without drawing them. +std::string ItemTooltip(const Request& request, std::unique_ptr& act); +// Writes the tooltip lines of every item at every level, in a few variants, to a text file (`out`): a snapshot to +// compare the tooltip code before and after a change with. +std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr& act); std::string ClickUi(const Request& request, std::unique_ptr& act); std::string HoverUi(const Request& request, std::unique_ptr& act); std::string DragUi(const Request& request, std::unique_ptr& act); diff --git a/src/source/App/Control/ControlCommandsQuery.cpp b/src/source/App/Control/ControlCommandsQuery.cpp index 2f2dff24f0..3fa5e43ea5 100644 --- a/src/source/App/Control/ControlCommandsQuery.cpp +++ b/src/source/App/Control/ControlCommandsQuery.cpp @@ -7,6 +7,11 @@ #include "Core/Utilities/Benchmark/BenchRecorder.h" #include "Core/Text/Utf8.h" #include "Engine/Object/ZzzCharacter.h" +#include "Data/GameData/ItemData/ItemDatabase.h" +#include "Data/GameData/ItemData/ItemType.h" +#include "Engine/Object/ZzzInventory.h" +#include "UI/Core/WindowSystem.h" +#include "UI/Inventory/ItemMng.h" #include "Network/Server/WSclient.h" #include "Scenes/SceneCore.h" #include "Scenes/SceneManager.h" @@ -20,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -228,7 +234,8 @@ std::string& MutableBuildIdentifier() } // Absolute path for a caller-given one, so the answer names a file the -// caller can open regardless of the client's working directory. +// caller can open regardless of the client's working directory. Used by every +// command that writes a file (`screenshot`, `tooltip-snapshot`). std::filesystem::path ResolveScreenshotPath(const std::string& requested) { // Through the wide form: a std::string given to std::filesystem::path is @@ -476,6 +483,213 @@ std::string State(const Request& request, std::unique_ptr&) return EncodeResult(request.EncodedId(), WorldStateObject()); } +namespace +{ +// An item as the server would send it: the fields not set are 0, the durability is full. +ItemCreationParams FullItem(int group, int number) +{ + ItemCreationParams params{}; + params.Group = group; + params.Number = number; + params.Durability = static_cast(ItemAttribute[group * MAX_ITEM_INDEX + number].Durability); + return params; +} + +// The tooltip lines of such an item and the hero as it is; nothing when the item can't be made. +std::vector TooltipOf(const ItemCreationParams& params) +{ + ITEM* item = g_pNewItemMng->CreateItemByParameters(¶ms); + std::vector lines = BuildItemTooltipLines(item, false); + g_pNewItemMng->DeleteItem(item); + return lines; +} + +// The kinds of item `tooltip-snapshot` records at every level: plain; with luck, skill, the +4 option and every +// excellent option; ancient (set 1); with a Jewel of Harmony option (option 1 at level 13). +struct TooltipVariant +{ + const char* name; + void (*apply)(ItemCreationParams& params); +}; +constexpr TooltipVariant TooltipVariants[] = { + {"plain", [](ItemCreationParams&) {}}, + {"full", + [](ItemCreationParams& params) + { + params.WithLuck = true; + params.WithSkill = true; + params.WithOption = true; + params.OptionLevel = 4; + params.HasExcellentOption = true; + params.ExcellentFlags = 63; + }}, + {"ancient", + [](ItemCreationParams& params) + { + params.IsAncient = true; + params.AncientDiscriminator = 1; + }}, + {"harmony", + [](ItemCreationParams& params) + { + params.HasHarmonyOption = true; + params.HarmonyOptionType = 1; + params.HarmonyOptionLevel = 13; + }}, +}; + +// Reads the integer `key` into `value` when the request has it; false when it is not a number from 0 to `max`. +bool ReadBounded(const Request& request, const char* key, int max, int& value) +{ + return !request.Has(key) || (request.GetInt(key, value) && value >= 0 && value <= max); +} +} // namespace + +std::string ItemTooltip(const Request& request, std::unique_ptr&) +{ + int group = -1; + int number = -1; + if (!request.GetInt("group", group) || !request.GetInt("number", number) || group < 0 || group >= MAX_ITEM_TYPE || + number < 0 || number >= MAX_ITEM_INDEX) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`tooltip` needs `group` and `number` of an item"); + } + + // The item's fields hold these values (as the item packet has them); a value they can't hold is a `bad_request`, + // not a wrapped one. Checked before the item is made from the item data. -1: left out. + int level = -1; + int durability = -1; + int option = 0; + int excellent = 0; + int ancient = 0; + int harmony = 0; + int harmonyLevel = -1; + if (!ReadBounded(request, "level", Data::Items::ItemLevelCount - 1, level) || + !ReadBounded(request, "durability", 255, durability) || !ReadBounded(request, "option", 15, option) || + !ReadBounded(request, "excellent", 63, excellent) || !ReadBounded(request, "ancient", 255, ancient) || + !ReadBounded(request, "harmony", 15, harmony) || !ReadBounded(request, "harmony_level", 15, harmonyLevel)) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`level`, `option`, `harmony` and `harmony_level` are 0 to 15, `excellent` the excellent " + "options' bits 0 to 63, `durability` and `ancient` 0 to 255"); + } + // A harmony level counts only with a harmony option; alone it would be ignored. + if (harmonyLevel >= 0 && harmony == 0) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`harmony_level` needs `harmony`, the Jewel of Harmony option"); + } + // An item number no item uses would answer with the tooltip of an empty item. + if (g_ItemDatabase.Find(group, number) == nullptr) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "no item has group " + std::to_string(group) + " and number " + std::to_string(number)); + } + + ItemCreationParams params = FullItem(group, number); + if (level >= 0) + params.Level = static_cast(level); + if (durability >= 0) + params.Durability = static_cast(durability); + bool flag = false; + if (request.GetBool("luck", flag)) + params.WithLuck = flag; + if (request.GetBool("skill", flag)) + params.WithSkill = flag; + if (option > 0) + { + params.WithOption = true; + params.OptionLevel = static_cast(option); + } + if (excellent > 0) + { + params.HasExcellentOption = true; + params.ExcellentFlags = static_cast(excellent); + } + if (ancient > 0) + { + params.IsAncient = true; + params.AncientDiscriminator = static_cast(ancient); + } + if (harmony > 0) + { + params.HasHarmonyOption = true; + params.HarmonyOptionType = static_cast(harmony); + if (harmonyLevel >= 0) + params.HarmonyOptionLevel = static_cast(harmonyLevel); + } + + json result; + result["lines"] = json::array(); + for (const ItemTooltipLine& line : TooltipOf(params)) + { + result["lines"].push_back( + {{"text", Core::Text::ToUtf8(line.text.c_str())}, {"color", line.color}, {"bold", line.bold}}); + } + return EncodeResult(request.EncodedId(), result.dump()); +} + +std::string ItemTooltipSnapshot(const Request& request, std::unique_ptr&) +{ + std::string path; + if (!request.GetString("out", path) || path.empty()) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, + "`tooltip-snapshot` needs `out`, the file to write"); + } + // One group per request keeps the frame that builds them short. Checked before `out` is opened: opening it + // empties it, and a mistyped request must not wipe a recorded snapshot. + int onlyGroup = -1; + if (request.Has("group") && (!request.GetInt("group", onlyGroup) || onlyGroup < 0 || onlyGroup >= MAX_ITEM_TYPE)) + { + return EncodeError(request.EncodedId(), ErrorCode::BadRequest, "`group` is an item group, 0 to 15"); + } + + const std::filesystem::path file = ResolveScreenshotPath(path); + std::ofstream out(file, std::ios::binary); + if (!out) + { + return EncodeError(request.EncodedId(), ErrorCode::Failed, "the file could not be written"); + } + + int tooltips = 0; + for (const Data::Items::ItemDefinition& definition : g_ItemDatabase.GetAllSlots()) + { + if (!definition.Exists() || (onlyGroup >= 0 && definition.group != onlyGroup)) + { + continue; + } + for (const TooltipVariant& variant : TooltipVariants) + { + for (int level = 0; level < Data::Items::ItemLevelCount; ++level) + { + ItemCreationParams params = FullItem(definition.group, definition.number); + params.Level = static_cast(level); + variant.apply(params); + out << definition.group << '/' << definition.number << ' ' << variant.name << " +" << level << '\n'; + for (const ItemTooltipLine& line : TooltipOf(params)) + { + const std::string text = line.text == L"\n" ? "" : Core::Text::ToUtf8(line.text.c_str()); + out << " " << line.color << (line.bold ? " bold " : " ") << text << '\n'; + } + ++tooltips; + } + } + } + // A full disk or a failed write leaves the file short: not a snapshot. + out.flush(); + if (!out) + { + return EncodeError(request.EncodedId(), ErrorCode::Failed, "the file could not be written completely"); + } + + json result; + result["tooltips"] = tooltips; + result["out"] = Core::Text::ToUtf8(file.wstring().c_str()); + return EncodeResult(request.EncodedId(), result.dump()); +} + std::string Nearby(const Request& request, std::unique_ptr&) { json result; diff --git a/src/source/App/Control/ControlDispatcher.cpp b/src/source/App/Control/ControlDispatcher.cpp index 5c7bb7b5cb..e21ea4342f 100644 --- a/src/source/App/Control/ControlDispatcher.cpp +++ b/src/source/App/Control/ControlDispatcher.cpp @@ -47,6 +47,8 @@ const std::vector& CommandTable() {"drag-ui", SceneRequirement::Any, &Commands::DragUi}, {"ui", SceneRequirement::World, &Commands::Ui}, {"slot-pixel", SceneRequirement::World, &Commands::SlotPixel}, + {"tooltip", SceneRequirement::World, &Commands::ItemTooltip}, + {"tooltip-snapshot", SceneRequirement::World, &Commands::ItemTooltipSnapshot}, {"login", SceneRequirement::PreGame, &Commands::Login}, {"select-char", SceneRequirement::PreGame, &Commands::SelectCharacter}, {"logout", SceneRequirement::World, &Commands::Logout}, diff --git a/src/source/App/Control/ControlProtocol.cpp b/src/source/App/Control/ControlProtocol.cpp index 1cae1a2bda..2e23543330 100644 --- a/src/source/App/Control/ControlProtocol.cpp +++ b/src/source/App/Control/ControlProtocol.cpp @@ -196,8 +196,7 @@ const std::vector& CommandNames() "ping", "scene", "state", "nearby", "events", "wait-for", "screenshot", "login", "select-char", "logout", "quit", "move", "warp", "teleport", "attack", "skill", "pickup", "use", "equip", "say", "whisper", "party", "halt", "hotkey", "click-ui", "drag-ui", "hover-ui", - "type", "ui", "slot-pixel", "trade", "console", "bench", - "bones", + "type", "ui", "slot-pixel", "trade", "console", "bench", "bones", "tooltip", "tooltip-snapshot", }; return names; } diff --git a/src/source/Core/Utilities/Debouncer.h b/src/source/Core/Utilities/Debouncer.h deleted file mode 100644 index 7846ce65a9..0000000000 --- a/src/source/Core/Utilities/Debouncer.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include - -class DebouncedAction -{ -public: - explicit DebouncedAction(std::function callback, int period) - : interval(std::chrono::milliseconds(period)), lastCallTime(), callback(std::move(callback)) - { - } - - void invoke() - { - auto now = std::chrono::high_resolution_clock::now(); - - // Call immediately on the first invocation or on the next interval - if (lastCallTime == std::chrono::high_resolution_clock::time_point() || now - lastCallTime >= interval) - { - callback(); - lastCallTime = now; - } - } - -private: - std::chrono::milliseconds interval; - std::chrono::high_resolution_clock::time_point lastCallTime; - std::function callback; -}; diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index 924f405906..027d10000e 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -21,7 +21,6 @@ #include "Scenes/SceneCore.h" -#include "Core/Utilities/Debouncer.h" #include "Core/Utilities/Log/MuLogger.h" #include "GameLogic/Quests/CSQuest.h" #include "App/Platform/Windows/Local.h" @@ -39,6 +38,7 @@ #include "GameLogic/Items/ItemCategories.h" #include "GameLogic/Items/ItemStats.h" #include "Render/Items/ItemDisplay.h" +#include #include "Data/GameData/ItemData/ItemModelSlots.h" #include "Data/GameData/ItemData/ItemDatabase.h" #include "Data/GameData/ItemData/ItemDescriptionPlaceholders.h" @@ -1631,6 +1631,20 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int // original tooltips. static constexpr int ItemDescriptionWrapWidth = 200; +std::optional GetPetInfoRequest(int itemType, int pointedIndex, int inventype) +{ + const StorageType storage = static_cast(inventype); + if (itemType == ITEM_DARK_HORSE_ITEM) + { + return pointedIndex == EQUIPMENT_HELPER + ? std::optional(PetInfoRequest{PetType::DarkHorse, storage, EQUIPMENT_HELPER}) + : std::nullopt; + } + return pointedIndex == EQUIPMENT_WEAPON_LEFT + ? std::optional(PetInfoRequest{PetType::DarkRaven, storage, EQUIPMENT_WEAPON_LEFT}) + : std::nullopt; +} + void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, int level, ItemTooltipTextMeasure measure) { const int lastRow = std::min(TextNum + MaxItemDescriptionRows, static_cast(std::size(TextList))); @@ -1671,25 +1685,27 @@ void WriteItemDescriptionLines(const Data::Items::ItemDefinition& definition, in } // (sx, sy) in the screen's 640x480 stretch, `screen`. -static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, ITEM* ip, bool Sell, int Inventype, - bool bItemTextListBoxUse) +// What BuildItemTooltipText made of an item. +enum class ItemTooltipBuild { - // Unconditional: the early returns below (and the pet-item delegation further down, which - // renders its own tooltip via giPetManager::RenderPetItemInfo() instead) used to mean "this - // frame draws nothing" under the old per-frame native draw -- already equivalent to "hidden" - // for that item. The shared tooltip document is persistent, so this replicates that; the real - // Show() call near the end of this function makes it visible again once actually reached. - UI::Tooltip::HideLegacyTextList(); + Nothing, // no tooltip for it + DarkLordPet, // the pet manager draws its own + Lines, // TextList, TextListColor and TextBold hold its TextNum lines +}; +// Writes the lines of an item's tooltip into TextList, TextListColor and TextBold (TextNum, SkipNum), without drawing +// them; RenderItemInfo draws them. +static ItemTooltipBuild BuildItemTooltipText(ITEM* ip, bool Sell, int Inventype, bool bItemTextListBoxUse) +{ if (ip->Type == -1) - return; + return ItemTooltipBuild::Nothing; tm* ExpireTime; if (ip->bPeriodItem == true && ip->bExpiredPeriod == false) { _tzset(); if (ip->lExpireTime == 0) - return; + return ItemTooltipBuild::Nothing; ExpireTime = localtime((time_t*)&(ip->lExpireTime)); } @@ -1706,28 +1722,7 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, if (!Sell && (GameLogic::Items::IsDarkLordPet(ip))) { - static DebouncedAction debouncedPetInfoRequest([ip, Inventype]() - { - PetType PetType = PetType::DarkRaven; - if (ip->Type == ITEM_DARK_HORSE_ITEM) - { - PetType = PetType::DarkHorse; - - if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_HELPER) - { - SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), EQUIPMENT_HELPER); - } - } - else if ((g_pMyInventory->GetPointedItemIndex()) == EQUIPMENT_WEAPON_LEFT) - { - SocketClient->ToGameServer()->SendPetInfoRequest(PetType, static_cast(Inventype), EQUIPMENT_WEAPON_LEFT); - } - }, 1000); // 1-second intervals - - debouncedPetInfoRequest.invoke(); - - giPetManager::RenderPetItemInfo(screen, sx, sy, ip, Inventype); - return; + return ItemTooltipBuild::DarkLordPet; } mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; @@ -2851,27 +2846,32 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, case ITEM_TYPE_CHARM_MIXWING + EWS_KNIGHT_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfDragon, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_MAGICIAN_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfSoul, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_ELF_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfSpirits, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_SUMMONER_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingOfDespair, Item_data.m_byValue1); - }break; + } + break; case ITEM_TYPE_CHARM_MIXWING + EWS_DARKKNIGHT_2_CHARM: { mu_swprintf(TextList[TextNum], I18N::Game::IncreasesYourLuckToCreateWingsOfDarkness, Item_data.m_byValue1); - }break; + } + break; } mu_swprintf(TextList[TextNum], I18N::Game::Lookup(2732 + (ip->Type - (ITEM_TYPE_CHARM_MIXWING + EWS_BEGIN))), - Item_data.m_byValue1); + Item_data.m_byValue1); TextListColor[TextNum] = TEXT_COLOR_BLUE; TextBold[TextNum] = false; @@ -2891,7 +2891,9 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - mu_swprintf(TextList[TextNum], L"\n"); TextNum++; SkipNum++; + mu_swprintf(TextList[TextNum], L"\n"); + TextNum++; + SkipNum++; mu_swprintf(TextList[TextNum], I18N::Game::DD, ip->Durability, 5); TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; @@ -2916,7 +2918,8 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - }break; + } + break; case ITEM_GAIONS_ORDER: { mu_swprintf(TextList[TextNum], I18N::Game::ItContainsGaionSPlansForTheDestructionOfTheEmpire); @@ -2944,7 +2947,8 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - }break; + } + break; case ITEM_COMPLETE_SECROMICON: { mu_swprintf(TextList[TextNum], I18N::Game::IndestructibleMetalSecromicon); @@ -2955,7 +2959,8 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextListColor[TextNum] = TEXT_COLOR_WHITE; TextBold[TextNum] = false; TextNum++; - }break; + } + break; } } else if (ITEM_OPEN_ACCESS_TICKET_TO_CHAOS_CASTLE == ip->Type) @@ -4362,7 +4367,62 @@ static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, TextNum = g_csItemOption.RenderSetOptionListInItem(ip, TextNum, bThisisEquippedItem); TextNum = g_SocketItemMgr.AttachToolTipForSocketItem(ip, TextNum); + } + return ItemTooltipBuild::Lines; +} + +std::vector BuildItemTooltipLines(ITEM* ip, bool Sell, int Inventype, bool bItemTextListBoxUse) +{ + std::vector lines; + if (BuildItemTooltipText(ip, Sell, Inventype, bItemTextListBoxUse) != ItemTooltipBuild::Lines) + { + return lines; + } + lines.reserve(TextNum); + for (int index = 0; index < TextNum; ++index) + { + lines.push_back({TextList[index], TextListColor[index], TextBold[index] != 0}); + } + return lines; +} + +static void RenderItemInfo(const UI::Scaling::Transform& screen, int sx, int sy, ITEM* ip, bool Sell, int Inventype, + bool bItemTextListBoxUse) +{ + // Unconditional: the early returns below (and the pet-item delegation further down, which + // renders its own tooltip via giPetManager::RenderPetItemInfo() instead) used to mean "this + // frame draws nothing" under the old per-frame native draw -- already equivalent to "hidden" + // for that item. The shared tooltip document is persistent, so this replicates that; the real + // Show() call near the end of this function makes it visible again once actually reached. + UI::Tooltip::HideLegacyTextList(); + + const ItemTooltipBuild build = BuildItemTooltipText(ip, Sell, Inventype, bItemTextListBoxUse); + if (build == ItemTooltipBuild::Nothing) + return; + if (build == ItemTooltipBuild::DarkLordPet) + { + // The pet's information is requested once a second at most, for the pet whose tooltip is drawn. + static std::chrono::steady_clock::time_point lastPetInfoRequest; + const auto now = std::chrono::steady_clock::now(); + if (lastPetInfoRequest == std::chrono::steady_clock::time_point() || + now - lastPetInfoRequest >= std::chrono::seconds(1)) + { + lastPetInfoRequest = now; + if (const std::optional request = + GetPetInfoRequest(ip->Type, g_pMyInventory->GetPointedItemIndex(), Inventype)) + { + SocketClient->ToGameServer()->SendPetInfoRequest(request->pet, request->storage, request->slot); + } + } + + giPetManager::RenderPetItemInfo(screen, sx, sy, ip, Inventype); + return; + } + + ITEM_ATTRIBUTE* p = &ItemAttribute[ip->Type]; + if (!bItemTextListBoxUse) + { SIZE TextSize = { 0, 0 }; int Height = 0; int EmptyLine = 0; diff --git a/src/source/Engine/Object/ZzzInventory.h b/src/source/Engine/Object/ZzzInventory.h index ca950132a3..d1f24751e7 100644 --- a/src/source/Engine/Object/ZzzInventory.h +++ b/src/source/Engine/Object/ZzzInventory.h @@ -1,6 +1,10 @@ #ifndef __ZZZINVENTORY_H__ #define __ZZZINVENTORY_H__ +#include +#include +#include + enum _COLUMN_TYPE { _COLUMN_TYPE_LEVEL = 0, @@ -97,6 +101,17 @@ namespace Data::Items struct ItemDefinition; } +// The server's pet information that a Dark Lord pet's tooltip asks for: the Dark Horse's when it is pointed at as the +// equipped helper, the Dark Raven's when it is pointed at in the left hand; nothing elsewhere. `inventype` is the +// tooltip's storage. +struct PetInfoRequest +{ + PetType pet; + StorageType storage; + BYTE slot; +}; +std::optional GetPetInfoRequest(int itemType, int pointedIndex, int inventype); + // How wide a text is in the tooltip's text units. using ItemTooltipTextMeasure = int (*)(const wchar_t* text, size_t length); // Writes the item data's description in the UI's language, with its placeholders filled in for the item's level, into @@ -209,6 +224,18 @@ void GetSpecialOptionText(int Type, wchar_t* Text, WORD Option, BYTE Value, int // The item's information and repair tooltips, anchored in window pixels: their layout runs in the // screen's 640x480 stretch, the space the original computed it in, whichever window asks. void RenderItemInfoAtPx(float x, float y, ITEM* ip, bool Sell, int Inventype = 0, bool bItemTextListBoxUse = false); +// One line of an item's information tooltip as RenderItemInfoAtPx builds it: its text ("\n" for a blank line), its +// TEXT_COLOR_* and whether it is bold. +struct ItemTooltipLine +{ + std::wstring text; + int color = 0; + bool bold = false; +}; +// The lines of `ip`'s information tooltip, built as RenderItemInfoAtPx builds them but not drawn; none when it shows +// no tooltip or the pet manager draws its own (the Dark Lord's pets). +std::vector BuildItemTooltipLines(ITEM* ip, bool Sell, int Inventype = 0, + bool bItemTextListBoxUse = false); void RenderRepairInfoAtPx(float x, float y, ITEM* ip, bool Sell); void RenderSkillInfo(int sx, int sy, int Type, int SkillNum = 0, int iRenderPoint = STRP_NONE); void RequireClass(ITEM_ATTRIBUTE* p); diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index 8ad366fd2f..3217349c20 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -111,6 +111,16 @@ if(ENABLE_CONTROL_SOCKET) LINK_LIBS MuClient ) + + # The tooltip commands' argument checks, which return before they need item data. + mu_add_test( + NAME test_control_tooltip_commands + SOURCES + test_control_tooltip_commands.cpp + ${CMAKE_SOURCE_DIR}/src/source/App/Platform/Windows/Winmain.cpp + LINK_LIBS + MuClient + ) endif() mu_add_test( diff --git a/tests/core/test_control_tooltip_commands.cpp b/tests/core/test_control_tooltip_commands.cpp new file mode 100644 index 0000000000..77672e65d9 --- /dev/null +++ b/tests/core/test_control_tooltip_commands.cpp @@ -0,0 +1,100 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "App/Control/ControlCommands.h" +#include "App/Control/ControlProtocol.h" + +#include +#include +#include +#include +#include + +namespace +{ +std::string Run(std::string (*command)(const App::Control::Request&, std::unique_ptr&), + const std::string& raw) +{ + std::unique_ptr act; + return command(App::Control::Request::Parse(raw), act); +} + +bool IsBadRequest(const std::string& response) +{ + return response.find(R"("error":"bad_request")") != std::string::npos; +} +} // namespace + +TEST_CASE("tooltip refuses a value its item's fields can't hold [core][control]") +{ + const auto tooltip = [](const std::string& fields) + { return Run(&App::Control::Commands::ItemTooltip, R"({"cmd":"tooltip","group":0,"number":0,)" + fields + "}"); }; + // Each would wrap around in the item's byte (or its packet's four bits) instead of being refused. + for (const char* fields : + {R"("level":-1)", R"("level":16)", R"("durability":-1)", R"("durability":256)", R"("option":-1)", + R"("option":16)", R"("excellent":64)", R"("excellent":256)", R"("ancient":-1)", R"("ancient":256)", + R"("harmony":16)", R"("harmony":1,"harmony_level":16)", R"("level":"high")"}) + { + INFO(fields); + const std::string response = tooltip(fields); + CHECK(IsBadRequest(response)); + CHECK(response.find("0 to 15") != std::string::npos); + } +} + +TEST_CASE("tooltip refuses a harmony level without a harmony option [core][control]") +{ + const std::string response = Run(&App::Control::Commands::ItemTooltip, + R"({"cmd":"tooltip","group":0,"number":0,"harmony_level":13})"); + CHECK(IsBadRequest(response)); + CHECK(response.find("`harmony_level` needs `harmony`") != std::string::npos); +} + +TEST_CASE("tooltip refuses an item number no item uses [core][control]") +{ + // The tests load no item data, so no item exists. + const std::string response = + Run(&App::Control::Commands::ItemTooltip, R"({"cmd":"tooltip","group":15,"number":511})"); + CHECK(IsBadRequest(response)); + CHECK(response.find("no item has group 15 and number 511") != std::string::npos); +} + +TEST_CASE("tooltip-snapshot leaves its file alone when the request is refused [core][control]") +{ + const std::filesystem::path file = std::filesystem::temp_directory_path() / "mu_tooltip_snapshot_keep.txt"; + { + std::ofstream(file, std::ios::binary) << "before"; + } + const std::string response = + Run(&App::Control::Commands::ItemTooltipSnapshot, + R"({"cmd":"tooltip-snapshot","out":")" + file.generic_string() + R"(","group":16})"); + CHECK(IsBadRequest(response)); + std::ostringstream text; + text << std::ifstream(file, std::ios::binary).rdbuf(); + CHECK(text.str() == "before"); + std::filesystem::remove(file); +} + +TEST_CASE("tooltip-snapshot replies the absolute path it wrote [core][control]") +{ + const std::filesystem::path previous = std::filesystem::current_path(); + std::filesystem::current_path(std::filesystem::temp_directory_path()); + const std::string response = Run(&App::Control::Commands::ItemTooltipSnapshot, + R"({"cmd":"tooltip-snapshot","out":"mu_tooltip_snapshot_relative.txt"})"); + const std::filesystem::path written = std::filesystem::absolute("mu_tooltip_snapshot_relative.txt"); + std::filesystem::current_path(previous); + CHECK(std::filesystem::exists(written)); + CHECK(response.find("mu_tooltip_snapshot_relative.txt") != std::string::npos); + CHECK(response.find("\"out\":\"mu_tooltip_snapshot_relative.txt\"") == std::string::npos); + std::filesystem::remove(written); +} + +TEST_CASE("tooltip-snapshot fails when it can't write its file [core][control]") +{ + // A folder can't be opened as the file. + const std::filesystem::path folder = std::filesystem::temp_directory_path(); + const std::string response = Run(&App::Control::Commands::ItemTooltipSnapshot, + R"({"cmd":"tooltip-snapshot","out":")" + folder.generic_string() + R"("})"); + CHECK(response.find(R"("error":"failed")") != std::string::npos); +} diff --git a/tests/data/CMakeLists.txt b/tests/data/CMakeLists.txt index 77a9eb6d69..536a1f9d11 100644 --- a/tests/data/CMakeLists.txt +++ b/tests/data/CMakeLists.txt @@ -74,6 +74,7 @@ mu_add_test( NAME test_item_database_files SOURCES test_helper_pickup_filters.cpp + test_dark_lord_pet_tooltip.cpp test_item_database_files.cpp test_item_descriptions.cpp test_item_model_files.cpp diff --git a/tests/data/test_dark_lord_pet_tooltip.cpp b/tests/data/test_dark_lord_pet_tooltip.cpp new file mode 100644 index 0000000000..fb423eb89f --- /dev/null +++ b/tests/data/test_dark_lord_pet_tooltip.cpp @@ -0,0 +1,33 @@ +#include "stdafx.h" + +#include "doctest.h" + +#include "Engine/Object/ZzzInventory.h" + +#include + +TEST_CASE("A Dark Lord pet's tooltip asks for the pet it is drawn for, also after another pet's [data][items][tooltip]") +{ + // The raven in the left hand, then the horse as the helper, then the raven again, from other storages. + const std::optional raven = GetPetInfoRequest(ITEM_DARK_RAVEN_ITEM, EQUIPMENT_WEAPON_LEFT, 0); + REQUIRE(raven.has_value()); + CHECK(raven->pet == PetType::DarkRaven); + CHECK(raven->slot == EQUIPMENT_WEAPON_LEFT); + CHECK(raven->storage == static_cast(0)); + + const std::optional horse = GetPetInfoRequest(ITEM_DARK_HORSE_ITEM, EQUIPMENT_HELPER, 2); + REQUIRE(horse.has_value()); + CHECK(horse->pet == PetType::DarkHorse); + CHECK(horse->slot == EQUIPMENT_HELPER); + CHECK(horse->storage == static_cast(2)); + + const std::optional ravenAgain = GetPetInfoRequest(ITEM_DARK_RAVEN_ITEM, EQUIPMENT_WEAPON_LEFT, 1); + REQUIRE(ravenAgain.has_value()); + CHECK(ravenAgain->pet == PetType::DarkRaven); + CHECK(ravenAgain->storage == static_cast(1)); + + // A pet pointed at anywhere else asks for nothing. + CHECK_FALSE(GetPetInfoRequest(ITEM_DARK_HORSE_ITEM, EQUIPMENT_WEAPON_LEFT, 0).has_value()); + CHECK_FALSE(GetPetInfoRequest(ITEM_DARK_RAVEN_ITEM, EQUIPMENT_HELPER, 0).has_value()); + CHECK_FALSE(GetPetInfoRequest(ITEM_DARK_HORSE_ITEM, MAX_EQUIPMENT + 3, 0).has_value()); +} diff --git a/tests/network/test_control_protocol.cpp b/tests/network/test_control_protocol.cpp index 052d432196..b4a6d1cfb6 100644 --- a/tests/network/test_control_protocol.cpp +++ b/tests/network/test_control_protocol.cpp @@ -86,8 +86,7 @@ TEST_CASE("Control protocol serves the documented command vocabulary [network][c "ping", "scene", "state", "nearby", "events", "wait-for", "screenshot", "login", "select-char", "logout", "quit", "move", "warp", "teleport", "attack", "skill", "pickup", "use", "equip", "say", "whisper", "party", "halt", "hotkey", "click-ui", "drag-ui", "hover-ui", - "type", "ui", "slot-pixel", "trade", "console", "bench", - "bones", + "type", "ui", "slot-pixel", "trade", "console", "bench", "bones", "tooltip", "tooltip-snapshot", }; for (const std::string& command : expected)