From 095bcb8d4cf6398679d66ec3739a5c51b07f0891 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 23:50:30 -0400 Subject: [PATCH 01/23] L182: the Cash Shop's result notices in v83's words The failure notice for each code now uses the text v83 maps it to (FUN_0047c17a), with its line breaks, and the default is v83's "the request for Cash Shop has failed". Codes for coupons, gift tokens and character transfers, which aren't served, keep the default. Buying bag or storage slots says "You have added slots to your item inventory." rather than the purchase text, and a sent gift shows the count, item, recipient and price in v83's format. The server's failure codes are documented with the texts the client now shows. --- .../Net/Handlers/CashShopHandlers.cpp | 63 +++++++++++++++---- agent_docs/client.md | 12 ++-- net/src/packet/build/world/cash_shop.rs | 12 ++-- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/HeavenClient/Net/Handlers/CashShopHandlers.cpp b/HeavenClient/Net/Handlers/CashShopHandlers.cpp index e56a764c0..5b74875f4 100644 --- a/HeavenClient/Net/Handlers/CashShopHandlers.cpp +++ b/HeavenClient/Net/Handlers/CashShopHandlers.cpp @@ -154,26 +154,63 @@ namespace ms return sns; } + // The notice for a failed request, as v83 shows it (FUN_0047c17a). + // Codes for coupons, gift tokens and character transfers, which + // there are none of here, get the default. std::string failure_message(uint8_t code) { switch (code) { + case 0xA3: + return "Request timed out.\\nPlease try again."; case 0xA5: return "You don't have enough cash."; + case 0xA6: + return "You can't buy someone a cash item gift if you're under 14."; + case 0xA7: + return "You have exceeded the allotted limit of price\\nfor gifts."; case 0xA8: - return "You cannot send a gift to your own account."; + return "You cannot send a gift to your own account.\\nPlease purchase it after logging\\nin with the related character."; case 0xA9: - return "Please confirm whether the character's name is correct."; + return "Please confirm whether\\nthe character's name is correct."; case 0xAA: - return "Due to gender restrictions, you can't buy this item."; + return "This item has a gender restriction.\\nPlease confirm the gender of the recipient."; + case 0xAB: + return "The gift cannot be sent because\\nthe recipient's Inventory is full."; + case 0xAC: + return "Please check and see if you have exceeded\\nthe number of cash items you can have."; + case 0xAD: + return "Please check and see\\nif the name of the character is wrong,\\nor if the item has gender restrictions."; case 0xBB: return "Please check if your inventory is full or not."; + case 0xBD: + return "You are sending a gift to an invalid recipient.\\nPlease check the character name and gender."; + case 0xBE: + return "Please check the name of the receiver."; + case 0xBF: + return "Items are not available for purchase\\n at this hour."; case 0xC0: - return "This item is out of stock, and therefore not available for sale."; + return "The item is out of stock, and therefore\\nnot available for sale."; + case 0xC1: + return "You have exceeded the spending limit of NX."; + case 0xC2: + return "You do not have enough mesos."; + case 0xC3: + return "The Cash Shop is unavailable\\nduring the beta-test phase.\\nWe apologize for your inconvenience."; case 0xC4: - return "Please check your birthday code."; + return "Check your birthday code and\\nplease try again."; + case 0xCD: + return "You have reached the daily maximum \\npurchase limit for the Cash Shop."; + case 0xE6: + return "This item cannot be purchased \\nwith MaplePoints."; + case 0xE7: + return "Sorry for inconvinence. \\nplease try again."; + case 0xE8: + return "This item cannot be\\npurchased by anyone under 7."; + case 0xE9: + return "This item cannot be\\nreceived by anyone under 7."; default: - return "Due to an unknown error, failed."; + return "Due to an unknown error,\\nthe request for Cash Shop has failed."; } } } @@ -267,23 +304,25 @@ namespace ms Stage::get().get_player().get_inventory().set_slotmax(type, static_cast(slots)); cashshop->refresh_slots(); - UI::get().emplace("You have successfully made the purchase.", [](bool) {}); + UI::get().emplace("You have added slots to your item inventory.", [](bool) {}); break; } case 0x62: - // The storage has more slots; the balance follows. + // The storage has more slots; the balance follows. v83 says + // the same as for a bag. recv.skip_short(); - UI::get().emplace("You have successfully made the purchase.", [](bool) {}); + UI::get().emplace("You have added slots to your item inventory.", [](bool) {}); break; case 0x5E: { // A gift is on its way; the balance follows. std::string to = recv.read_string(); int32_t item_id = recv.read_int(); - recv.skip_short(); // count - recv.skip_int(); // price + int16_t count = recv.read_short(); + int32_t price = recv.read_int(); - UI::get().emplace("You have sent " + ItemData::get(item_id).get_name() + " to " + to + ".", [](bool) {}); + UI::get().emplace(std::to_string(count) + " [ " + ItemData::get(item_id).get_name() + " ] \\nwas sent to " + to + + ". \\n" + std::to_string(price) + " NX Prepaid \\nwere spent in the process.", [](bool) {}); break; } case 0x68: diff --git a/agent_docs/client.md b/agent_docs/client.md index 378e53db9..4a83b8e88 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -571,12 +571,16 @@ the screen size. 0x06/0x07 mode 1 for an expansion commodity (911x000). As in v83, each mode is followed by a byte that is 1 for Maple Points, and 0x03 ends with a zero (no free trial). The result is a "successful purchase" or a failure - notice. + notice. Failure notices use v83's text for each code (`FUN_0047c17a`), + except coupon, gift-token and character-transfer codes, which get the + default. - **Check:** CHECK CASH sends CHECK_CASH (228), as does the game window getting the focus back while the shop is open; QUERY_CASH_RESULT answers. - **Slot buttons:** Equip Slot through Stor. Slot open `UICashBuy` for the matching expansion item at 4,000 NX, and OK sends 0x06 mode 0 with the bag, - or 0x07 mode 0. On 0x60 the bag's slot count changes at once. + or 0x07 mode 0. On 0x60 the bag's slot count changes at once. For 0x60 and + 0x62 the notice says "You have added slots to your item inventory.", as in + v83. - **Item data:** packages and expansions take their name and icon from `Item.nx` `Special`, pets from `Item.nx` `Pet`, and pet equipment from `Character.nx` `PetEquip`. @@ -592,8 +596,8 @@ the screen size. a To field and a message box that wraps over its two ruled rows (73 characters). OK, once both are filled, swaps the art for `CSNotice/1` and asks for the birthday code (YYYYMMDD). Its OK sends 0x04 with the code, SN, name - and message. The sender sees "You have sent ITEM to NAME." on 0x5E, or a - failure notice (0xA8 own account, 0xA9 unknown name, 0xC4 birthday code). + and message. On 0x5E the sender sees v83's "COUNT [ ITEM ] was sent to + NAME. PRICE NX Prepaid were spent in the process.", or a failure notice (0xA8 own account, 0xA9 unknown name, 0xC4 birthday code). The receiver gets one notice listing each gift's sender, item and message when 0x4D arrives. The buddy and guild buttons that open the wider `backgrnd1` aren't drawn, and only one name is sent. diff --git a/net/src/packet/build/world/cash_shop.rs b/net/src/packet/build/world/cash_shop.rs index 66d2f60a2..4ea4399f6 100644 --- a/net/src/packet/build/world/cash_shop.rs +++ b/net/src/packet/build/world/cash_shop.rs @@ -75,21 +75,23 @@ const BOUGHT_PACKAGE: u8 = 0xA4; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum CashShopFailure { - /// "Due to an unknown error, failed." + /// "Due to an unknown error, the request for Cash Shop has failed." Unknown = 0x00, /// "You don't have enough cash." NotEnoughCash = 0xA5, - /// "You cannot send a gift to your own account." + /// "You cannot send a gift to your own account. Please purchase it + /// after logging in with the related character." OwnAccount = 0xA8, /// "Please confirm whether the character's name is correct." NoSuchCharacter = 0xA9, - /// The item is for the other gender. + /// "This item has a gender restriction. Please confirm the gender of + /// the recipient." Gender = 0xAA, /// "Please check if your inventory is full or not." InventoryFull = 0xBB, - /// The item isn't on sale. + /// "The item is out of stock, and therefore not available for sale." SoldOut = 0xC0, - /// The birthday code isn't a date. + /// "Check your birthday code and please try again." BirthdayCode = 0xC4, } From 54669d564ec945851a3addca08a43f33809ca662 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 23:53:01 -0400 Subject: [PATCH 02/23] L182: SHOW_STATUS_INFO modes 2 and 6 to 13 as v83 shows them HeavenClient printed a red "Mode: N is not handled." for every mode past 5 and for mode 2. It now reads them as v83 does (FUN_00a209d4): guild points and an item's description as grey chat lines; expired cash items, items, seals and skills, and lines from the server, in the chat colour v83 uses for type 0xc; a quest's extra record silently. Any other mode is ignored, as in v83. --- .../Net/Handlers/MessagingHandlers.cpp | 63 ++++++++++++++++++- HeavenClient/Net/Handlers/StatusMessages.h | 9 +++ HeavenClient/tests/status_messages.cpp | 2 + agent_docs/client.md | 6 +- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index efbbbf97f..2448d3f34 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -24,6 +24,7 @@ #include "../../Character/Inventory/ScrollRules.h" #include "../../Data/CurseData.h" #include "../../Data/ItemData.h" +#include "../../Data/SkillData.h" #include "../../Data/SwindleData.h" #include "../../Gameplay/Stage.h" #include "../../IO/UI.h" @@ -151,12 +152,22 @@ namespace ms } } - // Modes: + // Modes, as v83 numbers them (FUN_00a209d4): // 0 - Picking up an item or mesos // 1 - Quest progress + // 2 - A cash item's time is up // 3 - EXP // 4 - Fame, in the chat log // 5 - Mesos, in the chat log + // 6 - Guild points + // 7 - An item's effect + // 8 - Items whose time is up + // 9 - A line from the server + // 10 - A quest's extra record, which shows nothing + // 11 - Items whose seal is gone + // 12 - Lines from the server + // 13 - Skills whose time is up + // v83 ignores any other mode. void ShowStatusInfoHandler::handle(InPacket& recv) const { int8_t mode = recv.read_byte(); @@ -248,9 +259,55 @@ namespace ms { chat_line(status_messages::meso_line(recv.read_int())); } - else + else if (mode == 2) { - show_status(Color::Name::RED, "Mode: " + std::to_string(mode) + " is not handled."); + const std::string& name = ItemData::get(recv.read_int()).get_name(); + + if (!name.empty()) + chat_line("[" + name + "] has passed its expiration date and will be removed from your inventory.", UIChatBar::LineType::SKILL); + } + else if (mode == 6) + { + chat_line(status_messages::guild_points_line(recv.read_int())); + } + else if (mode == 7) + { + chat_line(ItemData::get(recv.read_int()).get_desc()); + } + else if (mode == 8 || mode == 11) + { + // v83 stops at the first item it can't name. + for (auto count = static_cast(recv.read_byte()); count > 0; count--) + { + const std::string& name = ItemData::get(recv.read_int()).get_name(); + + if (name.empty()) + break; + + chat_line(mode == 8 ? "The item [" + name + "] has been expired, and therefore, deleted from your inventory." : name + "'s seal has expired.", UIChatBar::LineType::SKILL); + } + } + else if (mode == 9 || mode == 12) + { + auto count = static_cast(mode == 9 ? 1 : recv.read_byte()); + + for (; count > 0; count--) + { + std::string line = recv.read_string(); + + if (!line.empty()) + chat_line(line, UIChatBar::LineType::SKILL); + } + } + else if (mode == 13) + { + for (auto count = static_cast(recv.read_byte()); count > 0; count--) + { + const std::string& name = SkillData::get(recv.read_int()).get_name(); + + if (!name.empty()) + chat_line(name + " has disappeared as the time limit has passed.", UIChatBar::LineType::SKILL); + } } } diff --git a/HeavenClient/Net/Handlers/StatusMessages.h b/HeavenClient/Net/Handlers/StatusMessages.h index 025e67b2a..7055dac10 100644 --- a/HeavenClient/Net/Handlers/StatusMessages.h +++ b/HeavenClient/Net/Handlers/StatusMessages.h @@ -76,6 +76,15 @@ namespace ms return "You have gained fame. (+" + std::to_string(amount) + ")"; } + // Mode 6. Zero counts as a loss. + inline std::string guild_points_line(int32_t amount) + { + if (amount > 0) + return "You have earned Guild Points (+" + std::to_string(amount) + ")"; + + return "You have lost Guild Points (" + std::to_string(amount) + ")"; + } + // SHOW_STATUS_INFO mode 3, as FUN_00a21ac5 reads it. struct ExpGain { diff --git a/HeavenClient/tests/status_messages.cpp b/HeavenClient/tests/status_messages.cpp index 99c29c798..b8e4b070c 100644 --- a/HeavenClient/tests/status_messages.cpp +++ b/HeavenClient/tests/status_messages.cpp @@ -48,6 +48,8 @@ int main() require(meso_line(-150) == "You have lost mesos. (-150)", "losing mesos"); require(fame_line(1) == "You have gained fame. (+1)", "gaining fame"); require(fame_line(-1) == "You have lost fame (-1)", "losing fame"); + require(guild_points_line(5) == "You have earned Guild Points (+5)", "earning guild points"); + require(guild_points_line(0) == "You have lost Guild Points (0)", "losing none"); Writer kill; kill.exp(true, 25, false, 3); diff --git a/agent_docs/client.md b/agent_docs/client.md index 4a83b8e88..591decff2 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -77,7 +77,11 @@ from `UI.nx`. Moved so far: 120 px, or 96 px when a count follows), mesos, and kill EXP with a yellow line per bonus ("Bonus EXP for PARTY (+N)") show here; quest EXP (mode 3 with its in-chat flag), mode 4 fame and mode 5 mesos go to the chat log as - grey lines. + grey lines. So do mode 6 guild points and mode 7 (an item's description). + Expired cash items, items, seals and skills (modes 2, 8, 11, 13) and + server lines (9, 12) go to the chat log in the colour v83 uses for chat + type 0xc. Mode 10, a quest's extra record, shows nothing, and other modes + are ignored, as in v83. - **Chat** (`UIChatBar`): the input strip in the bar's top-left and the log above it. The status bar cuts that strip out of its background, so the two can overlap in either draw order. As in the original, the log has three From 9fe7bb8ad06b9ad2a12c9924b8728360eb4673d8 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 23:53:50 -0400 Subject: [PATCH 03/23] L182: confirm before passing the party leadership The Party Leader button sent the change at once. v83 first asks "Do you wish to pass the leader position to NAME?" and sends only on Yes (FUN_0090e41a), so HeavenClient now does the same. --- HeavenClient/IO/UITypes/UIUserList.cpp | 13 ++++++++++++- agent_docs/parties.md | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/HeavenClient/IO/UITypes/UIUserList.cpp b/HeavenClient/IO/UITypes/UIUserList.cpp index 2bee93f9b..be74d0c47 100644 --- a/HeavenClient/IO/UITypes/UIUserList.cpp +++ b/HeavenClient/IO/UITypes/UIUserList.cpp @@ -709,7 +709,18 @@ namespace ms break; case Buttons::BT_CHANGE_LEADER: if (member) - ChangePartyLeaderPacket(member->id).dispatch(); + { + int32_t id = member->id; + + UI::get().emplace( + "Do you wish to pass the leader position to \\n" + member->name + "?", + [id](bool yes) + { + if (yes) + ChangePartyLeaderPacket(id).dispatch(); + } + ); + } break; case Buttons::BT_WHISPER: diff --git a/agent_docs/parties.md b/agent_docs/parties.md index 3d08f67e6..7d1ba1849 100644 --- a/agent_docs/parties.md +++ b/agent_docs/parties.md @@ -64,6 +64,8 @@ reconstruction of v83: - Members are listed under online and offline. The leader has a star. Members off the player's map are greyed. - Clicking a row selects that member, for Party Leader, Whisper, and Expel. + Party Leader asks "Do you wish to pass the leader position to NAME?" + first, as v83 does (`FUN_0090e41a`). - Invite opens `UIEnterText` to ask for a name. - `UIPartyHP` is the translucent HP list at the top right (`UserList/Party/PartyHP`). `UIQuestHelper` moves down below it. From 84baf9023ad85c3c3cfb21a9bd45c5a8c259df51 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:02:04 -0400 Subject: [PATCH 04/23] L182: answer a bare /w NAME as v83 answers /whisper NAME Typing /w with a name and no message printed a red usage line that v83 never shows. v83's /whisper NAME refuses your own name, then checks the name as it checks a new character's (FUN_0079fc74: 4 to 12 letters or digits, fewer than four of 'I' and 'l') and says "Incorrect ID." in the notice colour if it fails; otherwise it switches the chat bar to whispering to that name. HC now does the same, filling in "/w NAME " as its other Whisper buttons do. A bare /w does nothing: v83 goes back to the last name whispered to, and HC keeps no list of them. --- HeavenClient/CMakeLists.txt | 3 +++ HeavenClient/IO/UITypes/UIChatBar.cpp | 37 ++++++++++++++++++++------- HeavenClient/Util/CharacterName.h | 24 +++++++++++++++++ HeavenClient/tests/character_name.cpp | 32 +++++++++++++++++++++++ agent_docs/client.md | 2 +- 5 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 HeavenClient/Util/CharacterName.h create mode 100644 HeavenClient/tests/character_name.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 8a4612bba..049a05311 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -46,6 +46,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME chat_wrap COMMAND chat_wrap_tests) add_executable(curse_words_tests tests/curse_words.cpp) add_test(NAME curse_words COMMAND curse_words_tests) + add_executable(character_name_tests tests/character_name.cpp) + add_test(NAME character_name COMMAND character_name_tests) add_executable(map_tooltip_tests tests/map_tooltip.cpp) add_test(NAME map_tooltip COMMAND map_tooltip_tests) add_executable(cash_catalog_tests tests/cash_catalog.cpp) @@ -710,6 +712,7 @@ add_executable(HeavenClient Template/Singleton.h Template/TimedQueue.h Template/TypeMap.h + Util/CharacterName.h Util/ChatWrap.h Util/CurseWords.h Util/HardwareInfo.h diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index ed133b370..e38f1e226 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -34,6 +34,7 @@ #include "../../Gameplay/Stage.h" #include "../../Graphics/GraphicsGL.h" #include "../../Net/Packets/MessagingPackets.h" +#include "../../Util/CharacterName.h" #include "../../Util/ChatWrap.h" #include "../../Util/NxFiles.h" @@ -44,7 +45,8 @@ namespace ms enum class WhisperParseResult { NotWhisper, - InvalidWhisper, + NoTarget, + TargetOnly, ValidWhisper }; @@ -68,19 +70,19 @@ namespace ms size_t target_start = input.find_first_not_of(' ', command_length); if (target_start == std::string::npos) - return WhisperParseResult::InvalidWhisper; + return WhisperParseResult::NoTarget; size_t target_end = input.find(' ', target_start); target = input.substr(target_start, target_end - target_start); - if (target.empty() || target_end == std::string::npos) - return WhisperParseResult::InvalidWhisper; + if (target_end == std::string::npos) + return WhisperParseResult::TargetOnly; size_t message_start = input.find_first_not_of(' ', target_end); if (message_start == std::string::npos) - return WhisperParseResult::InvalidWhisper; + return WhisperParseResult::TargetOnly; message = input.substr(message_start); - return message.empty() ? WhisperParseResult::InvalidWhisper : WhisperParseResult::ValidWhisper; + return WhisperParseResult::ValidWhisper; } // v83 compares whisper targets without regard to case (_stricmp). @@ -221,6 +223,7 @@ namespace ms { if (msg.size() > 0) { + std::string whisper_to; size_t last = msg.find_last_not_of(' '); if (last != std::string::npos) @@ -230,7 +233,8 @@ namespace ms std::string message; WhisperParseResult whisper_result = parse_whisper_command(msg, target, message); - if (whisper_result == WhisperParseResult::ValidWhisper + if ((whisper_result == WhisperParseResult::ValidWhisper + || whisper_result == WhisperParseResult::TargetOnly) && same_name(target, Stage::get().get_player().get_stats().get_name())) { // v83 refuses before sending anything (FUN_0052c40c). @@ -240,9 +244,21 @@ namespace ms { send_whisper(target, message); } - else if (whisper_result == WhisperParseResult::InvalidWhisper) + // A name alone switches to whispering to it, if it could + // be a character's (0x52e4b5). + else if (whisper_result == WhisperParseResult::TargetOnly + && !character_name_valid(target)) + { + send_chatline("Incorrect ID.", LineType::SKILL); + } + else if (whisper_result == WhisperParseResult::TargetOnly) + { + whisper_to = target; + } + // v83 goes back to the last name whispered to. HC keeps + // no list of them, so nothing happens. + else if (whisper_result == WhisperParseResult::NoTarget) { - send_chatline("Usage: /w ", LineType::RED); } else if (is_command(msg, "/?") || is_command(msg, "/Help")) { @@ -307,6 +323,9 @@ namespace ms } chatfield.change_text(""); + + if (!whisper_to.empty()) + start_whisper(whisper_to); } else { toggle_chatfield(); diff --git a/HeavenClient/Util/CharacterName.h b/HeavenClient/Util/CharacterName.h new file mode 100644 index 000000000..5e7656b7d --- /dev/null +++ b/HeavenClient/Util/CharacterName.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace ms +{ + // Whether a typed name could be a character's, as v83 checks it before + // asking the server (FUN_0079fc74), but for the forbidden words some + // callers add: 4 to 12 letters or digits, with fewer than four of 'I' + // and 'l' between them. + inline bool character_name_valid(const std::string& name) + { + if (name.size() < 4 || name.size() > 12) + return false; + + bool letters = std::all_of(name.begin(), name.end(), [](char c) + { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + }); + + return letters && std::count_if(name.begin(), name.end(), [](char c) { return c == 'I' || c == 'l'; }) < 4; + } +} diff --git a/HeavenClient/tests/character_name.cpp b/HeavenClient/tests/character_name.cpp new file mode 100644 index 000000000..e2ba64845 --- /dev/null +++ b/HeavenClient/tests/character_name.cpp @@ -0,0 +1,32 @@ +#include "../Util/CharacterName.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using ms::character_name_valid; + + require(character_name_valid("Bob1") && character_name_valid("TwelveLetter"), "4 to 12 letters or digits"); + require(!character_name_valid("Bob") && !character_name_valid("ThirteenChars"), "no fewer, no more"); + require(!character_name_valid("Bob hi") && !character_name_valid("Bob_1") && !character_name_valid("B\xc3\xa9" "bob"), + "nothing else"); + require(character_name_valid("IllX") && !character_name_valid("IllIX") && !character_name_valid("lllll"), + "fewer than four of I and l"); + require(character_name_valid("iiiiLLLL"), "other cases don't count"); + + std::cout << "Character name passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/client.md b/agent_docs/client.md index 591decff2..7fc8c06cc 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -427,7 +427,7 @@ side yet. | Skills, stat/AP/SP allocation, buffs, keymap | Implemented | | NPC dialogue and NPC shop | Implemented (`UINpcTalk`, `UIShop`) | | Quest log and markers | Implemented (`UIQuestLog`); progress arrives via SHOW_STATUS_INFO, quest start/complete is driven by NPC dialogue | -| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages | Implemented; buddy whisper/find modes ignored; other commands are dropped, as v83 does for non-GMs | +| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages | Implemented; buddy whisper/find modes ignored; `/w NAME` alone fills in a whisper to NAME, or says "Incorrect ID." for a name no character could have, as v83's `/whisper NAME` does; other commands are dropped, as v83 does for non-GMs | | Character info, minimap, world map | Implemented | | Party (`UIUserList` Party tab, `UIPartyHP`, party chat) | Implemented; see `parties.md` | | Buddy list (`UIUserList` Friend tab, buddy chat) | Implemented without groups; see `buddies.md` | From ed8cedbe2cb59204d56c7a97136d02748d257fdc Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:26:27 -0400 Subject: [PATCH 05/23] L182: answer refused quest steps the way v83 does A quest step that can't give its items, or a completion that takes more mesos than the character has, used to fail silently while the client waited. The server now answers with UPDATE_QUEST_INFO 10 (with the quest id) for a full inventory and 11 for missing mesos. HeavenClient handles the other UPDATE_QUEST_INFO results as v83 does (FUN_00959a73): 9, 11, 13 and 14 show their notices, 15 writes the expiry line to chat, and the timer modes are ignored. For 10 it names the inventories the step's Act.img items would go to, counting only items the character would get by job and gender, joined with " or" as v83 joins them, and adds the mesos clause when the step takes mesos. --- HeavenClient/CMakeLists.txt | 3 + HeavenClient/Character/QuestResult.h | 76 +++++++++++++++++ .../Net/Handlers/NpcInteractionHandlers.cpp | 83 +++++++++++++++++-- HeavenClient/tests/quest_result.cpp | 42 ++++++++++ agent_docs/client.md | 2 +- agent_docs/quests.md | 11 ++- integration-harness/src/trace.rs | 19 +++-- integration-harness/tests/quest_objectives.rs | 38 +++++++++ net/src/packet/build/world/quest.rs | 34 ++++++++ runtime/src/actor/client/quest.rs | 11 ++- 10 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 HeavenClient/Character/QuestResult.h create mode 100644 HeavenClient/tests/quest_result.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 049a05311..d02096bb4 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -48,6 +48,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME curse_words COMMAND curse_words_tests) add_executable(character_name_tests tests/character_name.cpp) add_test(NAME character_name COMMAND character_name_tests) + add_executable(quest_result_tests tests/quest_result.cpp) + add_test(NAME quest_result COMMAND quest_result_tests) add_executable(map_tooltip_tests tests/map_tooltip.cpp) add_test(NAME map_tooltip COMMAND map_tooltip_tests) add_executable(cash_catalog_tests tests/cash_catalog.cpp) @@ -315,6 +317,7 @@ add_executable(HeavenClient Character/QuestInfo.h Character/QuestLog.cpp Character/QuestLog.h + Character/QuestResult.h Character/SkillBook.cpp Character/SkillBook.h Character/SkillId.h diff --git a/HeavenClient/Character/QuestResult.h b/HeavenClient/Character/QuestResult.h new file mode 100644 index 000000000..1e498dfee --- /dev/null +++ b/HeavenClient/Character/QuestResult.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include + +namespace ms +{ + namespace quest_result + { + // An item of a quest step, from Act.img//<0|1>/item. + struct ActItem + { + int32_t id; + int32_t count; + // A mask of job families, 1 << (job / 100); -1 when absent. + int32_t job; + // 0 male, 1 female, 2 either. + int32_t gender; + }; + + // What v83 says when a quest step's items don't fit (quest result + // 10, FUN_00959a73): the inventories of the items the character + // would get, then the mesos too if the step takes some. v83 joins + // the inventories with " or" and no space after it. + inline std::string inventory_full(const std::vector& items, int32_t money, int16_t job, bool female) + { + std::set seen; + std::string inventories; + + for (const ActItem& item : items) + { + if (item.count <= 0) + continue; + + if (!(item.job & (1 << (job / 100))) && job / 100 != 9) + continue; + + if (item.gender != 2 && item.gender != (female ? 1 : 0)) + continue; + + int32_t type = item.id / 1000000; + + if (!seen.insert(type).second) + continue; + + if (!inventories.empty()) + inventories += " or"; + + switch (type) + { + case 1: + inventories += "Eqp"; + break; + case 2: + inventories += "Use"; + break; + case 3: + inventories += "Setup"; + break; + case 4: + inventories += "Etc"; + break; + } + } + + std::string text = inventories + " item inventory is full."; + + if (money < 0) + text = "Either you don't have enough Mesos or " + text; + + return text; + } + } +} diff --git a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp index 588ae6819..138a453d4 100644 --- a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp @@ -18,13 +18,18 @@ #include "NpcInteractionHandlers.h" #include "ShopList.h" +#include "../Character/QuestResult.h" +#include "../Gameplay/Stage.h" #include "../IO/UI.h" +#include "../IO/UITypes/UIChatBar.h" #include "../IO/UITypes/UINotice.h" #include "../IO/UITypes/UINpcTalk.h" #include "../IO/UITypes/UIShop.h" #include "../IO/UITypes/UIStorage.h" #include "../Gameplay/QuestConversation.h" +#include + namespace ms { void NpcDialogueHandler::handle(InPacket& recv) const @@ -48,19 +53,83 @@ namespace ms npctalk->change_text(npcid, msgtype, style, speaker, text); } + namespace + { + // The notice for a quest step whose items don't fit: the step the + // quest is at, taken from Act.img for this character. + std::string inventory_full(int16_t quest_id) + { + Player& player = Stage::get().get_player(); + std::string name = std::to_string(quest_id); + nl::node act = nl::nx::quest["Act.img"][name][player.get_quests().is_started(quest_id) ? "1" : "0"]; + std::vector items; + + for (size_t i = 0; act["item"][std::to_string(i)]; i++) + { + nl::node item = act["item"][std::to_string(i)]; + items.push_back({ static_cast(item["id"].get_integer()), + static_cast(item["count"].get_integer()), + static_cast(item["job"].get_integer(-1)), + static_cast(item["gender"].get_integer(2)) }); + } + + const CharStats& stats = player.get_stats(); + + return quest_result::inventory_full(items, static_cast(act["money"].get_integer()), + static_cast(stats.get_job().get_id()), stats.get_female()); + } + } + void UpdateQuestInfoHandler::handle(InPacket& recv) const { - int8_t mode = recv.read_byte(); + std::string notice; + + switch (recv.read_byte()) + { + case 8: + { + int16_t quest_id = recv.read_short(); + int32_t npc_id = recv.read_int(); + int16_t next_quest = recv.length() >= 2 ? recv.read_short() : 0; + + if (next_quest != 0) + QuestConversation::continue_with(next_quest, npc_id); - if (mode != 8) return; + } + case 9: + notice = "The quest has ended\\ndue to an unknown error."; + break; + case 10: + notice = inventory_full(recv.read_short()); + break; + case 11: + notice = "You do not have enough mesos."; + break; + case 13: + notice = "Unable to retrieve it due to the equipment\\n currently being worn by the character."; + break; + case 14: + notice = "You may not possess more than \\none of this item."; + break; + case 15: + { + std::string quest_id = std::to_string(recv.read_short()); + std::string name = nl::nx::quest["QuestInfo.img"][quest_id]["name"].get_string(); - int16_t quest_id = recv.read_short(); - int32_t npc_id = recv.read_int(); - int16_t next_quest = recv.length() >= 2 ? recv.read_short() : 0; + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("The [" + name + "] quest expired because the time limit ended", + UIChatBar::LineType::GREY); + + return; + } + default: + // 6, 7, 12 and 16 keep quest timers, which the server doesn't + // have. + return; + } - if (next_quest != 0) - QuestConversation::continue_with(next_quest, npc_id); + UI::get().emplace(notice, [](bool) {}); } void OpenNpcShopHandler::handle(InPacket& recv) const diff --git a/HeavenClient/tests/quest_result.cpp b/HeavenClient/tests/quest_result.cpp new file mode 100644 index 000000000..37fed2948 --- /dev/null +++ b/HeavenClient/tests/quest_result.cpp @@ -0,0 +1,42 @@ +#include "../Character/QuestResult.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using ms::quest_result::ActItem; + using ms::quest_result::inventory_full; + + require(inventory_full({ { 2000000, 5, -1, 2 } }, 0, 0, false) == "Use item inventory is full.", "one inventory"); + require(inventory_full({ { 2000000, 5, -1, 2 }, { 2000001, 1, -1, 2 }, { 4000000, 1, -1, 2 } }, 0, 0, false) + == "Use orEtc item inventory is full.", "each inventory once, joined as v83 does"); + require(inventory_full({ { 1302000, 1, -1, 2 }, { 3010000, 1, -1, 2 } }, -100, 0, false) + == "Either you don't have enough Mesos or Eqp orSetup item inventory is full.", "mesos taken"); + require(inventory_full({ { 4000000, -3, -1, 2 }, { 2000000, 0, -1, 2 } }, 0, 0, false) == " item inventory is full.", + "items taken don't count"); + + std::vector warrior_only = { { 1302000, 1, 1 << 1, 2 } }; + require(inventory_full(warrior_only, 0, 110, false) == "Eqp item inventory is full.", "the character's job"); + require(inventory_full(warrior_only, 0, 200, false) == " item inventory is full.", "another job"); + require(inventory_full(warrior_only, 0, 900, false) == "Eqp item inventory is full.", "GMs get every job's"); + + std::vector dress = { { 1051000, 1, -1, 1 } }; + require(inventory_full(dress, 0, 0, true) == "Eqp item inventory is full.", "the character's gender"); + require(inventory_full(dress, 0, 0, false) == " item inventory is full.", "the other gender"); + + std::cout << "Quest result passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/client.md b/agent_docs/client.md index 7fc8c06cc..656adfdd5 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -426,7 +426,7 @@ side yet. | Inventory, equip, item use, scroll, sort/gather | Implemented | | Skills, stat/AP/SP allocation, buffs, keymap | Implemented | | NPC dialogue and NPC shop | Implemented (`UINpcTalk`, `UIShop`) | -| Quest log and markers | Implemented (`UIQuestLog`); progress arrives via SHOW_STATUS_INFO, quest start/complete is driven by NPC dialogue | +| Quest log and markers | Implemented (`UIQuestLog`); progress arrives via SHOW_STATUS_INFO, quest start/complete is driven by NPC dialogue; UPDATE_QUEST_INFO refusals 9–11, 13 and 14 show v83's notices (10 names the full inventories from `Act.img`), 15 the expiry chat line, and the timer modes are ignored | | Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages | Implemented; buddy whisper/find modes ignored; `/w NAME` alone fills in a whisper to NAME, or says "Incorrect ID." for a name no character could have, as v83's `/whisper NAME` does; other commands are dropped, as v83 does for non-GMs | | Character info, minimap, world map | Implemented | | Party (`UIUserList` Party tab, `UIPartyHP`, party chat) | Implemented; see `parties.md` | diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 3d2b39218..2ba40d47b 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -27,7 +27,8 @@ Quests run the way the original client ran them: The server sends no quest dialogue and keeps no quest conversation state. Rejected actions (unsupported quest, wrong NPC or state, unmet job, level, or -prerequisite, NPC not nearby, replays) are logged at debug level and ignored. +prerequisite, NPC not nearby, replays) are logged at debug level and ignored; +only missing mesos and a full inventory get an answer (below). ## Supported quests @@ -61,13 +62,15 @@ magician, 0x8 bowman, 0x10 thief, 0x20 pirate, 0x400+ Cygnus) and `gender` a positive `prop`, one is picked at random by weight (Todd's beginner hats). `prop` -1 is a reward the player chooses, which needs UI and stays unsupported, as do `period`, `dateExpire`, `var`, and `name`. If the gifts -don't fit, nothing changes and the client gets "You can't get anymore -items." `startscript`/ +don't fit, nothing changes and the client gets UPDATE_QUEST_INFO mode 10 +with the quest id; it names the full inventories itself ("Etc item +inventory is full."). `startscript`/ `endscript` are allowed only for scripts the server implements (`game_data::quests::SCRIPTS`: `q1021s`, `q1021e`); those steps go through QUEST_ACTION 4/5 and the server drives the dialogue (see [NPC dialogue](./npc-dialogue.md)). `money` pays (or, if negative, takes) -mesos on completion; a quest that takes mesos needs them held. A quest with +mesos on completion; a quest that takes mesos needs them held, or the +client gets mode 11 ("You do not have enough mesos."). A quest with any other key (`pop`, `skill`, `infoex`, `pet`, ...) is **unsupported**: it is never offered, and actions for it are refused. A prerequisite without a diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index a85d6d22d..16f99318b 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -59,6 +59,9 @@ pub struct Trace { pub progress: Vec<(i16, u8)>, /// UPDATE_QUEST_INFO successes: quest, NPC, next quest. pub results: Vec<(i16, i32, i16)>, + /// Other UPDATE_QUEST_INFO results: mode, and the quest for those + /// that name one (else 0). + pub quest_refusals: Vec<(u8, i16)>, /// Latest progress string (kill counts) per started quest. pub kills: BTreeMap, /// EXP gains from SHOW_STATUS_INFO mode 3. @@ -331,12 +334,16 @@ impl Trace { } } } else if op == SendOpcode::UpdateQuestInfo as i16 { - assert_eq!(r.read_byte().unwrap(), 8, "only success results are sent"); - self.results.push(( - r.read_short().unwrap(), - r.read_int().unwrap(), - r.read_short().unwrap(), - )); + match r.read_byte().unwrap() { + 8 => self.results.push(( + r.read_short().unwrap(), + r.read_int().unwrap(), + r.read_short().unwrap(), + )), + mode => self + .quest_refusals + .push((mode, r.read_short().unwrap_or(0))), + } } else if op == SendOpcode::NpcDialogue as i16 { r.read_byte().unwrap(); let npc = r.read_int().unwrap(); diff --git a/integration-harness/tests/quest_objectives.rs b/integration-harness/tests/quest_objectives.rs index 715e5a545..0bf03936f 100644 --- a/integration-harness/tests/quest_objectives.rs +++ b/integration-harness/tests/quest_objectives.rs @@ -3,6 +3,7 @@ //! 1037/1038 (Sam's snails, Maria, and Lucas). use integration_harness::connection::MapleTestConnection; +use integration_harness::database::grant_item; use integration_harness::hunting::{attack, pickup, walk_to}; use integration_harness::preconditions::load_harness_config_or_fail; use integration_harness::quests::{act, COMPLETE, START}; @@ -25,6 +26,8 @@ const CANDY: i32 = 4031792; const SHELLPIECE: i32 = 4031802; const LETTER: i32 = 4031800; const BEGINNER_HATS: [i32; 5] = [1002053, 1002008, 1002014, 1002068, 1002069]; +const SNAIL_SHELL: i32 = 4000000; +const ETC: i16 = 4; /// Warp somewhere and wait for `count` of `mob_id` to show up. async fn enter_hunting_ground( @@ -85,6 +88,41 @@ async fn nina_gives_candy_that_sen_shares_with_beginners() { assert_eq!(restored.items.get(&2022253), Some(&2)); } +#[tokio::test] +async fn nina_keeps_her_candy_from_a_full_etc_inventory() { + let config = load_harness_config_or_fail().await; + let m = config.character_name.clone(); + let id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&config).await); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + for slot in 1..=24 { + grant_item(id, ETC, slot, SNAIL_SHELL) + .await + .expect("fill the Etc inventory"); + } + + let mut trace = Trace::default(); + let mut session = relogin(&config).await; + let c = &mut session.connection; + assert_eq!(warp(c, 20_000, "", &mut trace).await, 20_000); + act(c, &mut trace, &m, START, 1032, NINA).await; + assert_eq!(warp(c, 20_001, "", &mut trace).await, 20_001); + act(c, &mut trace, &m, COMPLETE, 1032, SEN).await; + act(c, &mut trace, &m, START, 1033, SEN).await; + assert_eq!(warp(c, 20_000, "", &mut trace).await, 20_000); + act(c, &mut trace, &m, COMPLETE, 1033, NINA).await; + + // The ten candies don't fit, so 1034 doesn't start. v83's quest result + // 10 names the quest; the client says which inventory is full. + act(c, &mut trace, &m, START, 1034, NINA).await; + assert_eq!(trace.quest_refusals, [(10, 1034)]); + assert_eq!(trace.states.get(&1034), None); + assert!(trace.items.is_empty(), "{:?}", trace.items); +} + #[tokio::test] async fn todds_hunt_needs_the_kill_and_the_shellpiece() { let config = load_harness_config_or_fail().await; diff --git a/net/src/packet/build/world/quest.rs b/net/src/packet/build/world/quest.rs index 23a4fb2a0..a17526b2f 100644 --- a/net/src/packet/build/world/quest.rs +++ b/net/src/packet/build/world/quest.rs @@ -49,6 +49,24 @@ pub fn build_quest_action_result( Ok(p) } +/// `UPDATE_QUEST_INFO` mode 10: the items of the quest's step don't fit. +/// The client names the inventories from the step's own items. +pub fn build_quest_items_full(quest_id: i16) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::UpdateQuestInfo as i16)?; + p.write_byte(10)?; + p.write_short(quest_id)?; + Ok(p) +} + +/// `UPDATE_QUEST_INFO` mode 11: "You do not have enough mesos." +pub fn build_quest_short_of_mesos() -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::UpdateQuestInfo as i16)?; + p.write_byte(11)?; + Ok(p) +} + /// The white "You have gained experience" line on the right; `party_bonus` /// is the extra EXP from sharing the kill with a party, shown on its own /// line under it. @@ -142,6 +160,22 @@ mod tests { expect.end(); } + #[test] + fn quest_refusals_carry_only_what_the_client_reads() { + let packet = build_quest_items_full(1032).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect + .short(SendOpcode::UpdateQuestInfo as i16) + .byte(10) + .short(1032); + expect.end(); + + let packet = build_quest_short_of_mesos().unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::UpdateQuestInfo as i16).byte(11); + expect.end(); + } + #[test] fn gain_exp_matches_reference() { let packet = build_gain_exp(25, 3).unwrap(); diff --git a/runtime/src/actor/client/quest.rs b/runtime/src/actor/client/quest.rs index 852d833c5..01190e8b6 100644 --- a/runtime/src/actor/client/quest.rs +++ b/runtime/src/actor/client/quest.rs @@ -59,6 +59,10 @@ impl ClientActor { ?rejection, "Quest action rejected" ); + if rejection == QuestRejection::Mesos { + let mut packets = vec![quest::build_quest_short_of_mesos()?]; + return self.send_all(&mut packets).await; + } return Ok(()); } self.npc_request_id = self.npc_request_id.wrapping_add(1); @@ -125,7 +129,7 @@ impl ClientActor { } let mut next = self.in_game()?.player.clone(); let mut packets = Vec::new(); - if !self.quest_items(&template.start_items, &mut next, &mut packets)? { + if !self.quest_items(template.id, &template.start_items, &mut next, &mut packets)? { return self.send_all(&mut packets).await; } if self @@ -147,7 +151,7 @@ impl ClientActor { } let mut next = self.in_game()?.player.clone(); let mut packets = Vec::new(); - if !self.quest_items(&template.end_rewards, &mut next, &mut packets)? { + if !self.quest_items(template.id, &template.end_rewards, &mut next, &mut packets)? { return self.send_all(&mut packets).await; } if template.money != 0 { @@ -192,6 +196,7 @@ impl ClientActor { /// "inventory full" notice queued, if the gifts don't fit. fn quest_items( &self, + quest_id: i16, items: &[QuestItem], next: &mut Player, packets: &mut Vec, @@ -202,7 +207,7 @@ impl ClientActor { Err(rejection) => { debug!(?rejection, "Quest items refused"); if rejection == QuestRejection::InventoryFull { - packets.push(inventory::build_inventory_full()?); + packets.push(quest::build_quest_items_full(quest_id)?); } return Ok(false); } From 1d02138f5e01429cc31500fae01e1718e3a975f3 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:26:35 -0400 Subject: [PATCH 06/23] L182: ask before spending AP on MaxHP or MaxMP v83 (FUN_00a239b7) ignores the HP and MP buttons below level 20 and, from level 20, asks whether to go ahead before sending SPEND_AP, warning that the character may end up weaker. The stats window now does the same; HeavenClient used to send the request at any level without asking. --- HeavenClient/IO/UITypes/UIStatsInfo.cpp | 22 ++++++++++++++++++++-- HeavenClient/IO/UITypes/UIStatsInfo.h | 2 ++ agent_docs/combat.md | 4 +++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/HeavenClient/IO/UITypes/UIStatsInfo.cpp b/HeavenClient/IO/UITypes/UIStatsInfo.cpp index e182f7f15..32b2ddeaa 100644 --- a/HeavenClient/IO/UITypes/UIStatsInfo.cpp +++ b/HeavenClient/IO/UITypes/UIStatsInfo.cpp @@ -16,6 +16,7 @@ // along with this program. If not, see . // ////////////////////////////////////////////////////////////////////////////////// #include "UIStatsInfo.h" +#include "UINotice.h" #include "../UI.h" @@ -225,10 +226,10 @@ namespace ms deactivate(); break; case Buttons::BT_HP: - send_apup(MapleStat::Id::MAXHP); + confirm_apup(MapleStat::Id::MAXHP); break; case Buttons::BT_MP: - send_apup(MapleStat::Id::MAXMP); + confirm_apup(MapleStat::Id::MAXMP); break; case Buttons::BT_STR: send_apup(MapleStat::Id::STR); @@ -260,6 +261,23 @@ namespace ms Stage::get().get_player().request(SpendApPacket(stat), ExclRequest::WAIT); } + void UIStatsInfo::confirm_apup(MapleStat::Id stat) const + { + // v83 ignores AP for MaxHP and MaxMP below level 20, and asks first + // from then on (FUN_00a239b7). + if (stats.get_stat(MapleStat::Id::LEVEL) < 20) + return; + + UI::get().emplace( + "If you invest your AP in HP or MP, your character may have\\ninsufficient stats to become as strong " + "as it could be.\\nDo you still wish to raise this skill?", + [stat](bool yes) + { + if (yes) + Stage::get().get_player().request(SpendApPacket(stat), ExclRequest::WAIT); + }); + } + void UIStatsInfo::set_detail(bool enabled) { showdetail = enabled; diff --git a/HeavenClient/IO/UITypes/UIStatsInfo.h b/HeavenClient/IO/UITypes/UIStatsInfo.h index 534bac39a..e60eb0a89 100644 --- a/HeavenClient/IO/UITypes/UIStatsInfo.h +++ b/HeavenClient/IO/UITypes/UIStatsInfo.h @@ -101,6 +101,8 @@ namespace ms void update_buffed(StatLabel label, EquipStat::Id stat, int32_t extra); void send_apup(MapleStat::Id stat) const; + // Ask before spending AP on MaxHP or MaxMP, as v83 does. + void confirm_apup(MapleStat::Id stat) const; void set_detail(bool enabled); diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 9a6924390..3118b7c89 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -284,7 +284,9 @@ so every request is answered, refusals with an empty STAT_CHANGED. - **AP** (`Player::spend_ap`): SPEND_AP names the stat by its STAT_CHANGED bit: STR, DEX, INT, LUK, MaxHP (0x800), or MaxMP (0x2000). Base stats stop at 999 and max HP/MP at 30,000. One AP adds the middle of v83's per-job - HP/MP range (`ap_growth`; a beginner gets +10/+7). + HP/MP range (`ap_growth`; a beginner gets +10/+7). As in v83, the client + only asks for MaxHP or MaxMP from level 20, and then only after the player + confirms a warning that it may leave the character weaker. - **SP** (`Player::raise_skill`): the skill must belong to the character's job or one it advanced through (`game::skill::in_job_tree`). Its `Skill.nx` template (`game_data::skills`) gives the max level and the `req` skills it From ea4b180d592105548b39c0a1e0caba81b1b4d23e Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:31:02 -0400 Subject: [PATCH 07/23] L182: open a world's channels only when the server answers v83 sends SERVERSTATUS_REQUEST when a world is picked and builds the channel list from the answer (FUN_006283c1), ignoring world and channel clicks while it waits. Status 1 shows the busy-world notice over the channels, and status 2 shows the full-world notice and leaves the list as it was. HeavenClient opened the scroll at once and dropped the answer. The second byte of the answer picks a busy banner for the channel list; it is read and not drawn. --- HeavenClient/IO/UITypes/UIWorldSelect.cpp | 48 +++++++++++++++++---- HeavenClient/IO/UITypes/UIWorldSelect.h | 9 ++++ HeavenClient/Net/Handlers/LoginHandlers.cpp | 13 +++--- agent_docs/client.md | 9 +++- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/HeavenClient/IO/UITypes/UIWorldSelect.cpp b/HeavenClient/IO/UITypes/UIWorldSelect.cpp index 935f5e4ee..677a3ecb5 100644 --- a/HeavenClient/IO/UITypes/UIWorldSelect.cpp +++ b/HeavenClient/IO/UITypes/UIWorldSelect.cpp @@ -20,6 +20,7 @@ #include "UILogin.h" #include "UILoginNotice.h" #include "UILoginWait.h" +#include "UINotice.h" #include "../LoginScene.h" #include "../UI.h" @@ -178,6 +179,8 @@ namespace ms break; } + int16_t pressed = status_pending ? requested : selected; + for (size_t i = 0; i < PLANK_SLOTS; i++) { Point pos = position + plank_pos(i); @@ -190,7 +193,7 @@ namespace ms const Plank &plank = planks[i]; - if (static_cast(i) == selected) + if (static_cast(i) == pressed) plank.pressed.draw(pos); else if (plank.hovered && plank.hover_done) plank.mouseover_end.draw(pos); @@ -479,21 +482,33 @@ namespace ms return Button::State::NORMAL; } - void UIWorldSelect::select_world(uint8_t index) + void UIWorldSelect::server_status(uint8_t status) { - if (index >= worlds.size() || index == selected) + // v83 stops waiting before it looks at the status. + status_pending = false; + + if (requested < 0 || status > 2) return; - Sound(Sound::Name::WORLDSELECT).play(); + int16_t index = requested; + requested = -1; + + if (status == 1) + UI::get().emplace("Since There Are Many\\nConcurrent Users in This World,\\nYou May Encounter Some " + "Difficulties During the Game Play.", [](bool) {}); + + if (status == 2) + { + UI::get().emplace("The Concurrent Users in This World\\nHave Reached the Max.\\nPlease Try Again Later.", + [](bool) {}); + return; + } selected = index; - focused = index; const World &world = worlds[selected]; world_name = NxFiles::ui_classic["Login.img"]["WorldSelect"]["world"][std::to_string(world.wid)]; - ServerStatusRequestPacket(world.wid).dispatch(); - if (channelid >= world.channelcount) channelid = 0; @@ -507,8 +522,25 @@ namespace ms } } + void UIWorldSelect::select_world(uint8_t index) + { + if (status_pending || index >= worlds.size() || index == selected) + return; + + Sound(Sound::Name::WORLDSELECT).play(); + + requested = index; + focused = index; + status_pending = true; + + ServerStatusRequestPacket(worlds[index].wid).dispatch(); + } + void UIWorldSelect::select_channel(uint8_t channel) { + if (status_pending) + return; + channelid = channel; channel_select.reset(); channel_select_done = false; @@ -529,7 +561,7 @@ namespace ms void UIWorldSelect::enter_world() { - if (scroll != Scroll::OPEN) + if (scroll != Scroll::OPEN || status_pending) return; auto worldid = static_cast(worlds[selected].wid); diff --git a/HeavenClient/IO/UITypes/UIWorldSelect.h b/HeavenClient/IO/UITypes/UIWorldSelect.h index 6936c9900..31e49a630 100644 --- a/HeavenClient/IO/UITypes/UIWorldSelect.h +++ b/HeavenClient/IO/UITypes/UIWorldSelect.h @@ -60,6 +60,11 @@ namespace ms void remove_selected(); + // The answer to the status request a picked world sent: 0 opens its + // channels, 1 also warns that it's busy, 2 says it's full and keeps + // the list (FUN_006283c1). + void server_status(uint8_t status); + // The v83 art for a world is keyed by the world id itself. std::string get_world_name(uint8_t worldid) const; uint8_t get_channel_count(uint8_t worldid) const; @@ -125,6 +130,10 @@ namespace ms // Slots into `worlds`; -1 when none. int16_t selected = -1; int16_t focused = -1; + // The world picked last, whose status is awaited. v83 takes no + // world or channel clicks until it comes (CLogin+0x170). + int16_t requested = -1; + bool status_pending = false; uint8_t channelid = 0; }; } diff --git a/HeavenClient/Net/Handlers/LoginHandlers.cpp b/HeavenClient/Net/Handlers/LoginHandlers.cpp index ab514d537..0064a7c6f 100644 --- a/HeavenClient/Net/Handlers/LoginHandlers.cpp +++ b/HeavenClient/Net/Handlers/LoginHandlers.cpp @@ -110,13 +110,14 @@ namespace ms void ServerStatusHandler::handle(InPacket& recv) const { - // Possible values for status: - // 0 - Normal - // 1 - Highly populated - // 2 - Full - recv.read_short(); // status + // 0 normal, 1 busy, 2 full (FUN_005f92ae). The second byte picks a + // busy banner (WorldNotice/Populated) for the channel list, which + // isn't drawn here. + uint8_t status = recv.read_byte(); + recv.read_byte(); - // TODO: I believe it shows a warning message if it's 1 and blocks enter into the world if it's 2. Need to find those messages. + if (auto worldselect = UI::get().get_element()) + worldselect->server_status(status); } void ServerlistHandler::handle(InPacket& recv) const diff --git a/agent_docs/client.md b/agent_docs/client.md index 656adfdd5..3da053ce3 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -269,8 +269,13 @@ from `UI.nx`. Moved so far: - **World select** (`UIWorldSelect`, `Login.img/WorldSelect`): the worlds hang in packet order as planks (`BtWorld/`) from the scene's rope bar. Fifteen slots start at x 232, 27 apart, and blank planks fill the - unused ones. Picking a world plays `scroll/0` over the scene's closed - scroll. When it opens, it shows the world's name and its channels in four + unused ones. Picking a world asks the server for its status + (SERVERSTATUS_REQUEST), and no world or channel takes clicks until the + answer comes. Status 0 plays `scroll/0` over the scene's closed scroll; 1 + does too, under a notice that the world is busy; 2 says the world is full + and leaves the list as it was. RustMS answers 2 only when no world has a + channel. The banner byte after the status is read but not drawn. When the + scroll opens, it shows the world's name and its channels in four columns, each with a load gauge. The gauge treats a load of 1000 as full, and RustMS sends each channel's configured capacity (700 by default) as its load. The red `chSelect` circle marks the chosen channel. Clicking it again, From c12d897de34f46cdd48155efbf90fdc3388435b3 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:36:29 -0400 Subject: [PATCH 08/23] L182: show v83's notices for shop and storage failures The shop result handler only knew codes 2 and 3 and stayed silent for the rest. It now shows the notice v83 does for each code, including the level limits (v83 prints the negated level for "under") and the low-level meso limit, and falls back to v83's error notice for codes it doesn't know. Storage had 0x0b and 0x0c the wrong way round on both sides. In v83, 0x0b is having too few mesos to take an item out and 0x0c is already having a one-of-a-kind item, so the server and client now use those values. --- .../Net/Handlers/NpcInteractionHandlers.cpp | 39 ++++++++++++++++--- agent_docs/client.md | 8 ++-- agent_docs/inventory.md | 4 +- net/src/storage.rs | 4 +- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp index 138a453d4..5e797e397 100644 --- a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp @@ -150,18 +150,47 @@ namespace ms void ConfirmShopTransactionHandler::handle(InPacket& recv) const { - const char* message = nullptr; + // The notice v83 shows for each code (FUN_00756da7). 0, 4 and 8 went through, and a code it doesn't know is an + // error. + std::string message = "Due to an error, the trade did not happen."; switch (recv.read_byte()) { + case 0: + case 4: + case 8: + return; + case 1: + case 5: + case 9: + message = "You don't have enough in stock."; + break; case 2: + case 10: message = "You do not have enough mesos."; break; case 3: message = "Please check if your inventory is full or not."; break; - default: - return; + case 13: + message = "You need more items"; + break; + case 14: + // v83 shows the level negated. + message = "You must be under lv." + std::to_string(-recv.read_int()) + " to purchase this item"; + break; + case 15: + message = "You must be over lv." + std::to_string(recv.read_int()) + " to purchase this item"; + break; + case 16: + message = "Players that are Level 15 and below \\nmay only trade 1 million mesos per day. \\nYou have reached the " + "limit today,\\nplease try again tomorrow."; + break; + case 17: + if (recv.read_bool()) + message = recv.read_string(); + + break; } UI::get().emplace(message, [](bool) {}); @@ -173,8 +202,8 @@ namespace ms { TAKEN_OUT = 0x09, INVENTORY_FULL = 0x0a, - ONLY_ONCE = 0x0b, - NO_MESOS_TO_TAKE = 0x0c, + NO_MESOS_TO_TAKE = 0x0b, + ONLY_ONCE = 0x0c, STORED = 0x0d, SORTED = 0x0f, NO_MESOS_TO_STORE = 0x10, diff --git a/agent_docs/client.md b/agent_docs/client.md index 3da053ce3..e037a6707 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -673,9 +673,11 @@ These were corrected: for the Rech. button, and buying sends the row's index in the server's list rather than its place on screen. Stars are bought one set at a time and sold as a whole set, both after a yes/no. The new - CONFIRM_SHOP_TRANSACTION handler shows "You do not have enough mesos." and - "Please check if your inventory is full or not."; codes 0 and 8 need no - notice. + CONFIRM_SHOP_TRANSACTION handler shows v83's notice for each code: codes + 0, 4 and 8 need none, and a code v83 doesn't know is "Due to an error, the + trade did not happen.". TRUNK_RESULT had swapped `0x0b`, which is out of + mesos to take an item out, and `0x0c`, which is a one-of-a-kind item the + player already has. 10. v83 keeps one binding each for Ctrl, Alt, and Shift: the game reads a key's scan code without the extended bit, so Right Ctrl and Right Alt arrive as the left keys, and it folds Right Shift (54) onto Left Shift diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 095396b40..7dba0552f 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -200,8 +200,8 @@ TRUNK_REQUEST (`0x3E`) is answered by mode: - **Take out** (4: tab, index): costs `trunkGet`. The item goes into a bag the same way a pickup does. Stars, bullets and equips get a slot of their - own. Answers TAKEN_OUT (`0x09`) with that bag. Errors: no mesos `0x0c`, a - one-of-a-kind item the player already has `0x0b`, full bag `0x0a`. + own. Answers TAKEN_OUT (`0x09`) with that bag. Errors: no mesos `0x0b`, a + one-of-a-kind item the player already has `0x0c`, full bag `0x0a`. - **Store** (5: slot, item, quantity): costs `trunkPut`. The quantity can be part of a stack. A set of stars or bullets and an equip go whole. Cash, quest and untradable items can't be stored. Answers STORED (`0x0d`) with diff --git a/net/src/storage.rs b/net/src/storage.rs index 06cc92bee..048bce0f4 100644 --- a/net/src/storage.rs +++ b/net/src/storage.rs @@ -20,9 +20,9 @@ pub mod result { pub const TAKEN_OUT: u8 = 0x09; /// "Please check if your inventory is full or not." pub const INVENTORY_FULL: u8 = 0x0a; + pub const TAKE_OUT_NO_MESOS: u8 = 0x0b; /// The player already has the one-of-a-kind item. - pub const ONLY_ONE: u8 = 0x0b; - pub const TAKE_OUT_NO_MESOS: u8 = 0x0c; + pub const ONLY_ONE: u8 = 0x0c; pub const STORED: u8 = 0x0d; pub const SORTED: u8 = 0x0f; pub const STORE_NO_MESOS: u8 = 0x10; From 9f17f518dac2dcf1b6ad4b3ce816875b17a40466 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:39:33 -0400 Subject: [PATCH 09/23] L182: word prompts the way v83 does The party, buddy and messenger prompts, the buddy delete question, the messenger close question and the meso drop prompt now use v83's texts. Number boxes take a minimum, which the "higher than" notice names, as v83's do. The meso drop box starts at 10 and refuses less, where it had let any amount from 1 through to the server. --- HeavenClient/IO/UITypes/EnterNumber.h | 7 +++++-- HeavenClient/IO/UITypes/UIItemInventory.cpp | 6 ++++-- HeavenClient/IO/UITypes/UIMessenger.cpp | 4 ++-- HeavenClient/IO/UITypes/UINotice.cpp | 10 ++++++---- HeavenClient/IO/UITypes/UINotice.h | 3 ++- HeavenClient/IO/UITypes/UIUserList.cpp | 6 +++--- HeavenClient/tests/enter_number.cpp | 2 ++ agent_docs/client.md | 3 ++- agent_docs/inventory.md | 3 ++- 9 files changed, 28 insertions(+), 16 deletions(-) diff --git a/HeavenClient/IO/UITypes/EnterNumber.h b/HeavenClient/IO/UITypes/EnterNumber.h index 1b561727c..af37b6a62 100644 --- a/HeavenClient/IO/UITypes/EnterNumber.h +++ b/HeavenClient/IO/UITypes/EnterNumber.h @@ -5,7 +5,7 @@ namespace ms { - // Checks what was typed into a number entry box against 1 to `max`. + // Checks what was typed into a number entry box against `min` to `max`. namespace enter_number { enum class Problem @@ -22,7 +22,7 @@ namespace ms int32_t number; }; - inline Checked check(const std::string &text, int32_t max) + inline Checked check(const std::string &text, int32_t max, int32_t min = 1) { if (text.find_first_not_of("0123456789") != std::string::npos) return { Problem::NOT_A_NUMBER, 0 }; @@ -40,6 +40,9 @@ namespace ms int64_t number = std::stoll(digits); + if (number < min) + return { Problem::TOO_LOW, 0 }; + if (number > max) return { Problem::TOO_HIGH, 0 }; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 92c0fbfcf..9f4dad925 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -294,7 +294,7 @@ namespace ms break; case Buttons::BT_COIN: { - // One drop holds 10 to 50,000 mesos. + // One drop holds 10 to 50,000 mesos, and the box starts at 10. constexpr int32_t MESO_DROP_MIN = 10; constexpr int32_t MESO_DROP_MAX = 50000; @@ -307,7 +307,9 @@ namespace ms Stage::get().get_player().request(MesoDropPacket(amount), ExclRequest::USE_WAIT); }; - UI::get().emplace("How many mesos will you drop?", onenter, most, MESO_DROP_MIN); + UI::get().emplace( + "How many will you drop? (A portion of the amount may be lost if picked up by another user.)", + onenter, most, MESO_DROP_MIN, MESO_DROP_MIN); } break; diff --git a/HeavenClient/IO/UITypes/UIMessenger.cpp b/HeavenClient/IO/UITypes/UIMessenger.cpp index 0b5a8bb45..0fe7e6097 100644 --- a/HeavenClient/IO/UITypes/UIMessenger.cpp +++ b/HeavenClient/IO/UITypes/UIMessenger.cpp @@ -338,7 +338,7 @@ namespace ms break; case Buttons::CLOSE: UI::get().emplace( - "Will you exit the chat room?", + "Will you exit this chat room?", [](bool yes) { if (!yes) @@ -431,7 +431,7 @@ namespace ms void UIMessenger::ask_invite() { UI::get().emplace( - "Whom would you like to invite?", + "Please type the name of the character, you want to invite.", [](const std::string &name) { if (auto messenger = UI::get().get_element()) diff --git a/HeavenClient/IO/UITypes/UINotice.cpp b/HeavenClient/IO/UITypes/UINotice.cpp index e1f86f7c3..17932e481 100644 --- a/HeavenClient/IO/UITypes/UINotice.cpp +++ b/HeavenClient/IO/UITypes/UINotice.cpp @@ -191,11 +191,13 @@ namespace ms return true; } - UIEnterNumber::UIEnterNumber(std::string message, std::function nh, int32_t m, int32_t quantity) + UIEnterNumber::UIEnterNumber(std::string message, std::function nh, int32_t m, int32_t quantity, + int32_t least) : UINotice(message, NoticeType::ENTERNUMBER) { numhandler = nh; max = m; + min = least; nl::node src = NxFiles::ui_classic["Basic.img"]; @@ -301,7 +303,7 @@ namespace ms buttons[Buttons::OK]->set_state(Button::State::NORMAL); }; - enter_number::Checked checked = enter_number::check(numstr, max); + enter_number::Checked checked = enter_number::check(numstr, max, min); std::string problem; switch (checked.problem) @@ -315,10 +317,10 @@ namespace ms problem = "Only numbers are allowed."; break; case enter_number::Problem::TOO_LOW: - problem = "You may only enter a number equal to or higher than 1."; + problem = "You may only enter a number \\nequal to or higher than " + std::to_string(min) + "."; break; case enter_number::Problem::TOO_HIGH: - problem = "You may only enter a number equal to or lower than " + std::to_string(max) + "."; + problem = "You may only enter a number \\nequal to or lower than " + std::to_string(max) + "."; break; } diff --git a/HeavenClient/IO/UITypes/UINotice.h b/HeavenClient/IO/UITypes/UINotice.h index 34bab4471..f03e59ab7 100644 --- a/HeavenClient/IO/UITypes/UINotice.h +++ b/HeavenClient/IO/UITypes/UINotice.h @@ -106,7 +106,7 @@ namespace ms static constexpr Type TYPE = UIElement::Type::ENTERNUMBER; UIEnterNumber(std::string message, std::function numhandler, int32_t max, - int32_t quantity); + int32_t quantity, int32_t min = 1); void draw(float alpha) const override; @@ -137,6 +137,7 @@ namespace ms ColorBox fieldfill; Textfield numfield; int32_t max; + int32_t min; }; // Asks for a line of text, such as a character name. diff --git a/HeavenClient/IO/UITypes/UIUserList.cpp b/HeavenClient/IO/UITypes/UIUserList.cpp index be74d0c47..8e87ef98b 100644 --- a/HeavenClient/IO/UITypes/UIUserList.cpp +++ b/HeavenClient/IO/UITypes/UIUserList.cpp @@ -747,7 +747,7 @@ namespace ms break; case Buttons::BT_INVITE: UI::get().emplace( - "Who do you want to invite?", + "Please enter the name of the character\\nyou'll invite to your party.", [](const std::string& name) { PartyCommands::invite(name); @@ -757,7 +757,7 @@ namespace ms break; case Buttons::BT_ADD_BUDDY: UI::get().emplace( - "Enter the character name you'd like to add as a buddy.", + "Please enter the name of the character\\nyou'll add as a buddy.", [](const std::string& name) { AddBuddyPacket(name, DEFAULT_GROUP).dispatch(); @@ -771,7 +771,7 @@ namespace ms int32_t id = buddy->id; UI::get().emplace( - "Are you sure you want to delete '" + buddy->name + "'\\nfrom your buddy list?", + "Are you sure you want to delete\\n'" + buddy->name + "' from the list?", [id](bool yes) { if (yes) diff --git a/HeavenClient/tests/enter_number.cpp b/HeavenClient/tests/enter_number.cpp index 0b15981f8..f33894be5 100644 --- a/HeavenClient/tests/enter_number.cpp +++ b/HeavenClient/tests/enter_number.cpp @@ -24,6 +24,8 @@ int main() require(check("2147483647", 2147483647).number == 2147483647, "the largest maximum is accepted"); require(check("1a", 50).problem == Problem::NOT_A_NUMBER, "letters are not a number"); require(check("-5", 50).problem == Problem::NOT_A_NUMBER, "a sign is not a number"); + require(check("9", 50000, 10).problem == Problem::TOO_LOW, "below a higher minimum is too low"); + require(check("10", 50000, 10).number == 10, "the minimum itself is accepted"); std::cout << "Enter number passed\n"; return 0; diff --git a/agent_docs/client.md b/agent_docs/client.md index e037a6707..18ca5f2f6 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -204,7 +204,8 @@ from `UI.nx`. Moved so far: `BtOK`/`BtCancel` for number entry). Number entry draws a white field under the message. The click that opens it no longer takes the focus from its field, so typed amounts count. `UIEnterNumber` has its own element type, so the notice about a - bad number opens over it, and OK goes back to the field. + bad number opens over it, and OK goes back to the field. As in v83, the notice names the box's real + minimum, which is 10 for dropping mesos. Prompts use v83's words. - **Quit** (`ask_to_quit`): MENU's QUIT GAME and closing the window ask "Are you sure you want to quit?" in a `UIYesNo`; yes goes back to the login screen. The later playtime and EXP summary (`UIWindow6.img/askReward`) is diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 7dba0552f..119b22662 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -49,7 +49,8 @@ whole stack; anything else drops the asked quantity, keeping equipment's stats (`Loot::Dropped`). Quest and `tradeBlock` items leave the bag but don't reach the floor: everyone sees them vanish (DROP_ITEM_FROM_MAPOBJECT 3). MESO_DROP (`0x5E`: tick, amount) drops 10 to 50,000 mesos the player holds; -HeavenClient's meso button asks how many with `UIEnterNumber`. The field +HeavenClient's meso button asks how many with `UIEnterNumber`, which, as in +v83, starts at 10 and takes no less. The field places either kind where it last saw the player, and anyone may pick it up at once (ownership 2). Dead players can't drop. From bb318d4b64ac2473a07a3a68c75b56a8a48188ee Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 00:47:49 -0400 Subject: [PATCH 10/23] L182: write Monster Book card lines the way v83 does v83's MONSTER_BOOK_SET_CARD handler (FUN_00a081b8) prints "[card] has been successfully recorded on the Monster Book." when a card goes into the book and "This card is already full in the Monster Book. This card will disappear." when the page already holds five. Both lines use chat type 0xc. For a full page it reads only the leading byte. The client now does the same. Both lines were checked in the GUI. --- HeavenClient/Net/Handlers/PlayerHandlers.cpp | 21 ++++++++++++++------ agent_docs/combat.md | 6 ++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/HeavenClient/Net/Handlers/PlayerHandlers.cpp b/HeavenClient/Net/Handlers/PlayerHandlers.cpp index 662eba067..710e8c89f 100644 --- a/HeavenClient/Net/Handlers/PlayerHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerHandlers.cpp @@ -356,20 +356,29 @@ namespace ms void MonsterBookSetCardHandler::handle(InPacket& recv) const { - bool added = recv.read_bool(); - int32_t card = recv.read_int(); - int32_t level = recv.read_int(); + // v83 writes both lines in chat type 0xc and reads nothing more for a full card (FUN_00a081b8). + auto chatbar = UI::get().get_element(); + + if (!recv.read_bool()) + { + if (chatbar) + chatbar->send_chatline("This card is already full in the Monster Book. This card will disappear.", + UIChatBar::LineType::SKILL); - if (!added) return; + } + + int32_t card = recv.read_int(); + int32_t level = recv.read_int(); Stage::get().get_player().get_monsterbook().set_card(card, level); - if (auto chatbar = UI::get().get_element()) + if (chatbar) { const std::string &name = ItemData::get(card).get_name(); - chatbar->send_chatline("[" + name + "] has been successfully registered in the Monster Book.", UIChatBar::LineType::PINK); + chatbar->send_chatline("[" + name + "] has been successfully recorded on the Monster Book.", + UIChatBar::LineType::SKILL); } if (auto monsterbook = UI::get().get_element()) diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 3118b7c89..56fefc448 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -365,8 +365,10 @@ the v83 book from `UIClassic.nx`, laid out from 2008 footage: three, four and five. These come from `String.nx/MonsterBook.img`. - Right-clicking a collected card offers SET AS COVER / REMOVE COVER. -A new card also prints "[Card name] has been successfully registered in the -Monster Book." in the chat log in pink (`#FFA194`, from footage). +A new card also prints "[Card name] has been successfully recorded on the +Monster Book." in the chat log, and a card for a full page prints "This card +is already full in the Monster Book. This card will disappear.". Both use +chat type 0xc, as in v83 (`#FFAFAF`). ## Mob statuses Players' skills put temporary statuses on mobs (`game::MobStat`, by their From 3f34f608d4dca123567be08c27586edd32dea8e5 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 01:13:00 -0400 Subject: [PATCH 11/23] L182: refuse skills with v83's cost and weapon lines HeavenClient now checks a skill's mesos and item cost after its HP and MP, as v83's FUN_00764256 does, and says "You don't have enough mesos to use this skill." or names the missing item. Shadow Meso's cost grows by Shadow Partner's y percent while that buff is on. The four cost lines share one two-second throttle, so a second refusal within two seconds prints nothing. The weapon check follows DoActiveSkill and its handlers (skill_weapon). Shots need their job's weapon whatever Skill.img names, and Brandish, Corkscrew Blow, Grenade and Poison Bomb need theirs; these say "You cannot use this skill with the weapon you're equipped with." The held shots refuse a wrong weapon silently. Any other mismatch with the weapon the skill names, or the knuckle, sword and wand rules FUN_00766157 gives some pirate and Cygnus skills, now says "This skill cannot be used with the equipped item." Shadow Stars without enough stars has its own line, and the bullet line gets its period back. --- HeavenClient/CMakeLists.txt | 2 + HeavenClient/Character/Player.cpp | 27 +++- HeavenClient/Character/SkillId.h | 4 +- HeavenClient/Data/SkillData.cpp | 12 +- HeavenClient/Data/SkillData.h | 4 + HeavenClient/Gameplay/Combat/Combat.cpp | 38 ++++-- HeavenClient/Gameplay/Combat/Skill.cpp | 18 ++- HeavenClient/Gameplay/Combat/SkillWeapon.h | 142 +++++++++++++++++++++ HeavenClient/Gameplay/Combat/SpecialMove.h | 4 + HeavenClient/IO/Messages.cpp | 29 ++++- HeavenClient/IO/Messages.h | 18 ++- HeavenClient/IO/UITypes/UIChatBar.cpp | 29 ++++- HeavenClient/IO/UITypes/UIChatBar.h | 9 +- HeavenClient/tests/skill_weapon.cpp | 49 +++++++ agent_docs/combat.md | 19 +++ 15 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 HeavenClient/Gameplay/Combat/SkillWeapon.h create mode 100644 HeavenClient/tests/skill_weapon.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index d02096bb4..1104dd50b 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -212,6 +212,8 @@ if(HEAVENCLIENT_BUILD_TESTS) SUPPORTED_QUESTS="${PROJECT_SOURCE_DIR}/../game-data/supported_quests.txt" DEFAULT_QUEST_NX="${PROJECT_SOURCE_DIR}/../assets/game-data/Quest.nx") add_test(NAME quest_catalog COMMAND quest_catalog_tests) + add_executable(skill_weapon_tests tests/skill_weapon.cpp) + add_test(NAME skill_weapon COMMAND skill_weapon_tests) endif() include_directories(.) diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 67712fa5e..2a2ba909c 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -18,6 +18,7 @@ #include "Player.h" #include "PlayerStates.h" +#include "SkillId.h" #include "../Audio/Audio.h" #include "../Data/EquipData.h" @@ -607,7 +608,31 @@ namespace ms uint16_t mp = stats.get_stat(MapleStat::Id::MP); uint16_t bullets = inventory.get_most_bullets(); - return move.can_use(level, weapon, job, hp, mp, bullets); + SpecialMove::ForbidReason reason = move.can_use(level, weapon, job, hp, mp, bullets); + + // v83 checks the skill's mesos and item right after its HP and MP, + // before the weapon (FUN_00764256). + if (!move.is_skill() || reason == SpecialMove::ForbidReason::FBR_OTHER || + reason == SpecialMove::ForbidReason::FBR_HPCOST || reason == SpecialMove::ForbidReason::FBR_MPCOST) + return reason; + + const SkillData::Stats &cost = SkillData::get(move.get_id()).get_stats(level); + int32_t mesos = cost.mesocost; + + // Shadow Meso costs more while Shadow Partner is on. + if (move.get_id() == SkillId::SHADOW_MESO && has_buff(Buffstat::Id::SHADOWPARTNER)) + { + int32_t partner = skillbook.get_level(SkillId::SHADOW_PARTNER); + mesos += SkillData::get(SkillId::SHADOW_PARTNER).get_stats(partner).y * mesos / 100; + } + + if (mesos > 0 && inventory.get_meso() < mesos) + return SpecialMove::ForbidReason::FBR_MESOCOST; + + if (cost.itemcost > 0 && cost.itemcount > 0 && inventory.get_total_item_count(cost.itemcost) < cost.itemcount) + return SpecialMove::ForbidReason::FBR_ITEMCOST; + + return reason; } Attack Player::prepare_attack(int32_t skill, bool whack) const diff --git a/HeavenClient/Character/SkillId.h b/HeavenClient/Character/SkillId.h index 151191e99..3e53fac93 100644 --- a/HeavenClient/Character/SkillId.h +++ b/HeavenClient/Character/SkillId.h @@ -154,7 +154,9 @@ namespace ms PRIEST_TELEPORT = 2301001, // Hermit - AVENGER = 4111005, + SHADOW_PARTNER = 4111002, + SHADOW_MESO = 4111004, + AVENGER = 4111005, FLASH_JUMP = 4111006, // Night Lord diff --git a/HeavenClient/Data/SkillData.cpp b/HeavenClient/Data/SkillData.cpp index bfcbf683a..34a82ae51 100644 --- a/HeavenClient/Data/SkillData.cpp +++ b/HeavenClient/Data/SkillData.cpp @@ -71,13 +71,17 @@ namespace ms int32_t y = sub["y"]; int32_t level = string_conversion::or_default(sub.name(), -1); - stats.emplace( + Stats &added = stats.emplace( std::piecewise_construct, std::forward_as_tuple(level), std::forward_as_tuple(damage, matk, fixdamage, mastery, attackcount, mobcount, bulletcount, bulletcost, hpcost, mpcost, chance, critical, ignoredef, hrange, range, hp, mp, time, shotrange, y) - ); + ).first->second; + + added.mesocost = sub["moneyCon"]; + added.itemcost = sub["itemCon"]; + added.itemcount = sub["itemConNo"]; } element = std::string(src["elemAttr"]); @@ -89,10 +93,6 @@ namespace ms else reqweapon = Weapon::by_value(100 + (int32_t) src["weapon"]); - // Grenade says nothing of its gun, but needs one (0x96a86e). - if (id == 5201002) - reqweapon = Weapon::Type::GUN; - masterlevel = static_cast(stats.size()); passive = (id % 10000) / 1000 == 0; flags = flags_of(id); diff --git a/HeavenClient/Data/SkillData.h b/HeavenClient/Data/SkillData.h index a3b2378e7..525cd4da7 100644 --- a/HeavenClient/Data/SkillData.h +++ b/HeavenClient/Data/SkillData.h @@ -60,6 +60,10 @@ namespace ms int16_t shotrange; // The skill's own value, as Element Amplification's damage percent. int32_t y; + // The mesos the skill takes, and the item and how many of it. + int32_t mesocost = 0; + int32_t itemcost = 0; + int32_t itemcount = 0; constexpr Stats(float damage, int32_t matk, int32_t fixdamage, int32_t mastery, uint8_t attackcount, uint8_t mobcount, uint8_t bulletcount, int16_t bulletcost, int32_t hpcost, int32_t mpcost, diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 02eab53e1..84a2c73fe 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -298,17 +298,37 @@ namespace ms SpecialMove::ForbidReason reason = player.can_use(move); Weapon::Type weapontype = player.get_stats().get_weapontype(); - if (reason != SpecialMove::ForbidReason::FBR_NONE) - { - ForbidSkillMessage(reason, weapontype).drop(); - return false; + // v83 says why it won't attack bare-handed (FUN_0095b85a) once + // DoActiveSkill has let the skill's cost through, before it looks at + // the weapon or the ammo. Buffs need no weapon. + switch (reason) + { + case SpecialMove::ForbidReason::FBR_NONE: + case SpecialMove::ForbidReason::FBR_WEAPONTYPE: + case SpecialMove::ForbidReason::FBR_EQUIPPED: + case SpecialMove::ForbidReason::FBR_BULLETCOST: + if (move.is_attack() && !player.get_look().get_equips().has_weapon()) + { + InChatMessage(Messages::ATTACK_NOWEAPON).drop(); + return false; + } + + break; + default: + break; } - // v83 says why it won't attack bare-handed (FUN_0095b85a), once - // DoActiveSkill has let the skill through. Buffs need no weapon. - if (move.is_attack() && !player.get_look().get_equips().has_weapon()) + if (reason != SpecialMove::ForbidReason::FBR_NONE) { - InChatMessage(Messages::ATTACK_NOWEAPON).drop(); + int32_t item = 0; + + if (reason == SpecialMove::ForbidReason::FBR_ITEMCOST) + { + int32_t level = player.get_skills().get_level(move.get_id()); + item = SkillData::get(move.get_id()).get_stats(level).itemcost; + } + + ForbidSkillMessage(reason, weapontype, item).drop(); return false; } @@ -721,7 +741,7 @@ namespace ms if (reach.star == 0) { - InChatMessage(Messages::SKILL_NOSTARS).drop(); + InChatMessage(Messages::SKILL_SHADOWSTARS).drop(); return std::nullopt; } } diff --git a/HeavenClient/Gameplay/Combat/Skill.cpp b/HeavenClient/Gameplay/Combat/Skill.cpp index bcaeb8806..2bb654a48 100644 --- a/HeavenClient/Gameplay/Combat/Skill.cpp +++ b/HeavenClient/Gameplay/Combat/Skill.cpp @@ -19,6 +19,7 @@ #include "Heal.h" #include "Prepare.h" +#include "SkillWeapon.h" #include "../../Character/Char.h" #include "../../Character/SkillId.h" @@ -401,12 +402,21 @@ namespace ms } } - Weapon::Type reqweapon = SkillData::get(skillid).get_required_weapon(); + const SkillData &data = SkillData::get(skillid); - if (weapon != reqweapon && reqweapon != Weapon::NONE) - return FBR_WEAPONTYPE; + switch (skill_weapon::check(skillid, data.is_shot(), weapon, data.get_required_weapon())) + { + case skill_weapon::WEAPON: + return FBR_WEAPONTYPE; + case skill_weapon::EQUIPPED: + return FBR_EQUIPPED; + case skill_weapon::QUIET: + return FBR_OTHER; + default: + break; + } - if (!SkillData::get(skillid).uses_ammo()) + if (!data.uses_ammo()) return FBR_NONE; switch (weapon) diff --git a/HeavenClient/Gameplay/Combat/SkillWeapon.h b/HeavenClient/Gameplay/Combat/SkillWeapon.h new file mode 100644 index 000000000..2c67b9490 --- /dev/null +++ b/HeavenClient/Gameplay/Combat/SkillWeapon.h @@ -0,0 +1,142 @@ +#pragma once + +#include + +#include "../../Character/Inventory/Weapon.h" + +namespace ms +{ + // Whether a skill may be used with the equipped weapon, as v83's + // DoActiveSkill and its handlers decide. + namespace skill_weapon + { + enum Refusal + { + FITS, + // "You cannot use this skill with the weapon you're equipped + // with." (0xb1c) + WEAPON, + // "This skill cannot be used with the equipped item." (0x1116) + EQUIPPED, + // Refused without a word. + QUIET + }; + + // The shots FUN_009696a2 fires whatever the weapon. + inline bool any_weapon(int32_t skill) + { + switch (skill) + { + case 4121003: // Taunt + case 4221003: // Taunt + case 5121002: // Energy Orb + case 11101004: // Soul Blade + case 15111007: // Shark Wave + case 21100004: // Combo Smash + case 21110004: // Combo Penril + case 21120006: // Combo Tempest + return true; + default: + return false; + } + } + + // The weapons FUN_00766157 lets a skill be used with, given the one + // its Skill.img names. + inline bool fits(int32_t skill, Weapon::Type weapon, Weapon::Type required) + { + switch (skill) + { + case 5001001: // Flash Fist + case 15001001: // Straight + return weapon == Weapon::KNUCKLE || weapon == Weapon::GUN; + case 5101002: // Backspin Blow + case 5101003: // Double Uppercut + case 5101004: // Corkscrew Blow + case 5111002: // Energy Blast + case 5111006: // Shockwave + case 5121001: // Dragon Strike + case 5121002: // Energy Orb + case 5121004: // Demolition + case 5121005: // Snatch + case 5121007: // Barrage + case 15101003: // Corkscrew Blow + case 15101005: // Energy Blast + case 15111003: // Shockwave + case 15111004: // Barrage + return weapon == Weapon::KNUCKLE; + case 11101004: // Soul Blade + case 11111006: // Soul Driver + return weapon == Weapon::SWORD_1H || weapon == Weapon::SWORD_2H; + case 12101006: // Fire Pillar + case 12111005: // Flame Gear + case 12111006: // Fire Strike + return weapon == Weapon::WAND || weapon == Weapon::STAFF; + default: + return required == Weapon::NONE || weapon == required; + } + } + + // shot: whether FUN_009696a2 fires the skill. + inline Refusal check(int32_t skill, bool shot, Weapon::Type weapon, Weapon::Type required) + { + bool wrong = false; + + switch (skill) + { + // FUN_00950921 swings Brandish only with a sword or an axe. + case 1121008: + case 11111004: + wrong = weapon != Weapon::SWORD_1H && weapon != Weapon::AXE_1H && weapon != Weapon::SWORD_2H && + weapon != Weapon::AXE_2H; + break; + // The held skills (0x96a86e). + case 5101004: // Corkscrew Blow + case 15101003: + wrong = weapon != Weapon::KNUCKLE; + break; + case 5201002: // Grenade + wrong = weapon != Weapon::GUN; + break; + case 14111006: // Poison Bomb + wrong = weapon != Weapon::CLAW; + break; + case 3121004: // Hurricane + case 3221001: // Piercing Arrow + case 5221004: // Rapid Fire + case 13111002: // Hurricane + if (weapon != Weapon::BOW && weapon != Weapon::CROSSBOW && weapon != Weapon::GUN) + return QUIET; + + break; + default: + // A shot needs the weapon of its job, Skill.img aside. + if (shot && !any_weapon(skill)) + { + switch ((skill / 10000) % 1000 / 100) + { + case 3: + wrong = weapon != Weapon::BOW && weapon != Weapon::CROSSBOW; + break; + case 4: + wrong = weapon != Weapon::CLAW; + break; + case 5: + wrong = weapon != Weapon::GUN; + break; + default: + wrong = true; + break; + } + } + + break; + } + + if (wrong) + return WEAPON; + + return fits(skill, weapon, required) ? FITS : EQUIPPED; + } + } +} diff --git a/HeavenClient/Gameplay/Combat/SpecialMove.h b/HeavenClient/Gameplay/Combat/SpecialMove.h index be28bcdb8..cff6d386d 100644 --- a/HeavenClient/Gameplay/Combat/SpecialMove.h +++ b/HeavenClient/Gameplay/Combat/SpecialMove.h @@ -33,8 +33,12 @@ namespace ms { FBR_NONE, FBR_WEAPONTYPE, + // The weapon doesn't suit the skill (skill_weapon::EQUIPPED). + FBR_EQUIPPED, FBR_HPCOST, FBR_MPCOST, + FBR_MESOCOST, + FBR_ITEMCOST, FBR_BULLETCOST, FBR_COOLDOWN, FBR_ONEHANDED, diff --git a/HeavenClient/IO/Messages.cpp b/HeavenClient/IO/Messages.cpp index 8f6f33bfa..bbc772e06 100644 --- a/HeavenClient/IO/Messages.cpp +++ b/HeavenClient/IO/Messages.cpp @@ -21,41 +21,50 @@ #include "UITypes/UIChatBar.h" +#include "../Data/ItemData.h" + namespace ms { const EnumMap Messages::messages = { "", "You cannot use this skill with the weapon you're equipped with.", + "This skill cannot be used with the equipped item.", "You don't have enough HP to use this skill.", "You don't have enough MP to use this skill.", + "You don't have enough mesos to use this skill.", + "You don't have enough %s to use this skill.", "You have used up all of your arrows.", - "You do not have enough bullets to use this skill", + "You do not have enough bullets to use this skill.", + "You have used up all of your throwing-stars.", "You do not have enough throwing stars to use this skill.", - "You cannot use this skill as it is on cooldown.", + "You may not use the skill yet.", "You may only use this skill with a one-handed weapon equipped.", "You cannot use that skill on this map.", "You cannot use the Mystic Door Skill near the portal.", "Since you are not using a weapon, you are unable to attack." }; - InChatMessage::InChatMessage(Messages::Type t) + bool Messages::is_cost(Type type) { - type = t; + return type == SKILL_HPCOST || type == SKILL_MPCOST || type == SKILL_MESOCOST || type == SKILL_ITEMCOST; } + InChatMessage::InChatMessage(Messages::Type t, std::string i) : type(t), item(std::move(i)) + {} + void InChatMessage::drop() const { if (type == Messages::Type::NONE) return; if (auto chatbar = UI::get().get_element()) - chatbar->display_message(type, UIChatBar::SKILL); + chatbar->display_message(type, UIChatBar::SKILL, item); } - ForbidSkillMessage::ForbidSkillMessage(SpecialMove::ForbidReason reason, Weapon::Type weapon) : InChatMessage( - message_by_reason(reason, weapon)) + ForbidSkillMessage::ForbidSkillMessage(SpecialMove::ForbidReason reason, Weapon::Type weapon, int32_t item) + : InChatMessage(message_by_reason(reason, weapon), item ? ItemData::get(item).get_name() : "") {} Messages::Type ForbidSkillMessage::message_by_reason(SpecialMove::ForbidReason reason, Weapon::Type weapon) @@ -64,10 +73,16 @@ namespace ms { case SpecialMove::FBR_WEAPONTYPE: return Messages::SKILL_WEAPONTYPE; + case SpecialMove::FBR_EQUIPPED: + return Messages::SKILL_EQUIPPED; case SpecialMove::FBR_HPCOST: return Messages::SKILL_HPCOST; case SpecialMove::FBR_MPCOST: return Messages::SKILL_MPCOST; + case SpecialMove::FBR_MESOCOST: + return Messages::SKILL_MESOCOST; + case SpecialMove::FBR_ITEMCOST: + return Messages::SKILL_ITEMCOST; case SpecialMove::FBR_COOLDOWN: return Messages::SKILL_COOLDOWN; case SpecialMove::FBR_ONEHANDED: diff --git a/HeavenClient/IO/Messages.h b/HeavenClient/IO/Messages.h index e42fdce36..d61e0a5ae 100644 --- a/HeavenClient/IO/Messages.h +++ b/HeavenClient/IO/Messages.h @@ -20,6 +20,8 @@ #include "../Character/Inventory/Weapon.h" #include "../Gameplay/Combat/SpecialMove.h" +#include + namespace ms { namespace Messages @@ -29,11 +31,17 @@ namespace ms NONE, // Cannot use a skill SKILL_WEAPONTYPE, + SKILL_EQUIPPED, SKILL_HPCOST, SKILL_MPCOST, + SKILL_MESOCOST, + // Names the item in place of %s + SKILL_ITEMCOST, SKILL_NOARROWS, SKILL_NOBULLETS, SKILL_NOSTARS, + // Shadow Stars finds no stack of stars + SKILL_SHADOWSTARS, SKILL_COOLDOWN, SKILL_ONEHANDED, // The map forbids the skill (fieldLimit) @@ -46,23 +54,29 @@ namespace ms }; extern const EnumMap messages; + + // Whether the line says what a skill costs. v83 says these at most + // every two seconds between them. + bool is_cost(Type type); }; class InChatMessage { public: - InChatMessage(Messages::Type type); + InChatMessage(Messages::Type type, std::string item = ""); void drop() const; private: Messages::Type type; + std::string item; }; class ForbidSkillMessage : public InChatMessage { public: - ForbidSkillMessage(SpecialMove::ForbidReason reason, Weapon::Type weapon); + // item: the item the skill takes, for FBR_ITEMCOST + ForbidSkillMessage(SpecialMove::ForbidReason reason, Weapon::Type weapon, int32_t item = 0); private: Messages::Type message_by_reason(SpecialMove::ForbidReason reason, Weapon::Type weapon); diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index e38f1e226..66fcd2993 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -448,6 +448,8 @@ namespace ms for (auto iter : message_cooldowns) iter.second -= Constants::TIMESTEP; + cost_cooldown -= Constants::TIMESTEP; + if (whisper_waiting && std::chrono::steady_clock::now() - whisper_sent > WHISPER_WAIT) whisper_waiting = false; @@ -689,17 +691,30 @@ namespace ms rowpos = rowmax; } - void UIChatBar::display_message(Messages::Type line, UIChatBar::LineType type) + void UIChatBar::display_message(Messages::Type line, UIChatBar::LineType type, const std::string &item) { - if (message_cooldowns[line] > 0) - return; + if (Messages::is_cost(line)) + { + bool quiet = cost_cooldown > 0; + cost_cooldown = COST_MESSAGE_COOLDOWN; + + if (quiet) + return; + } else + { + if (message_cooldowns[line] > 0) + return; + + message_cooldowns[line] = line == Messages::ATTACK_NOWEAPON ? COST_MESSAGE_COOLDOWN : MESSAGE_COOLDOWN; + } std::string message = Messages::messages[line]; - send_chatline(message, type); + size_t name = message.find("%s"); - bool slow = line == Messages::SKILL_HPCOST || line == Messages::SKILL_MPCOST || - line == Messages::ATTACK_NOWEAPON; - message_cooldowns[line] = slow ? COST_MESSAGE_COOLDOWN : MESSAGE_COOLDOWN; + if (name != std::string::npos) + message.replace(name, 2, item); + + send_chatline(message, type); } void UIChatBar::toggle_chat() diff --git a/HeavenClient/IO/UITypes/UIChatBar.h b/HeavenClient/IO/UITypes/UIChatBar.h index 5de3c16cf..2d53ea0e4 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.h +++ b/HeavenClient/IO/UITypes/UIChatBar.h @@ -103,7 +103,8 @@ namespace ms void send_chatline(const std::string &line, LineType type); - void display_message(Messages::Type line, UIChatBar::LineType type); + // item: the name the line gives in place of %s + void display_message(Messages::Type line, UIChatBar::LineType type, const std::string &item = ""); // Expand or collapse the log above the status bar. void toggle_chat(); @@ -196,8 +197,9 @@ namespace ms static constexpr int16_t EDGE_OFFSET = 5; static constexpr int16_t LINE_LOG_HEIGHT = 24; static constexpr time_t MESSAGE_COOLDOWN = 1'000; - // The original repeats a missing HP or MP notice, or the missing weapon - // one, at most every two seconds. + // The original repeats the missing weapon notice at most every two + // seconds, and a missing HP, MP, mesos or item one only after two + // seconds without one of them, said or not (user+0x3108). static constexpr time_t COST_MESSAGE_COOLDOWN = 2'000; enum Buttons : uint16_t @@ -246,6 +248,7 @@ namespace ms std::string whisper_message; EnumMap message_cooldowns; + time_t cost_cooldown = 0; ChatFloodGuard flood_guard; std::vector lastentered; size_t lastpos; diff --git a/HeavenClient/tests/skill_weapon.cpp b/HeavenClient/tests/skill_weapon.cpp new file mode 100644 index 000000000..73708b098 --- /dev/null +++ b/HeavenClient/tests/skill_weapon.cpp @@ -0,0 +1,49 @@ +#include "../Gameplay/Combat/SkillWeapon.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms::skill_weapon; + namespace W = ms::Weapon; + + require(check(3001004, true, W::BOW, W::NONE) == FITS && check(3001004, true, W::CROSSBOW, W::NONE) == FITS, + "an archer's shot takes a bow or a crossbow"); + require(check(3001004, true, W::CLAW, W::NONE) == WEAPON, "but nothing else, whatever Skill.img says"); + require(check(4001344, true, W::BOW, W::CLAW) == WEAPON && check(5201001, true, W::CLAW, W::GUN) == WEAPON, + "a thief's needs a claw, a pirate's a gun"); + require(check(13001003, true, W::CROSSBOW, W::NONE) == FITS && check(14001004, true, W::CLAW, W::CLAW) == FITS, + "Cygnus shots go by the job too"); + require(check(3101005, true, W::CROSSBOW, W::BOW) == EQUIPPED, "then the weapon Skill.img names"); + require(check(15111007, true, W::KNUCKLE, W::KNUCKLE) == FITS && check(15111007, true, W::BOW, W::KNUCKLE) == EQUIPPED, + "Shark Wave skips the job's weapon"); + + require(check(1121008, false, W::AXE_2H, W::NONE) == FITS && check(1121008, false, W::SPEAR, W::NONE) == WEAPON, + "Brandish takes a sword or an axe"); + require(check(5101004, false, W::GUN, W::NONE) == WEAPON && check(5201002, false, W::KNUCKLE, W::NONE) == WEAPON + && check(14111006, false, W::BOW, W::CLAW) == WEAPON, "the held skills say which weapon"); + require(check(3121004, true, W::CLAW, W::BOW) == QUIET && check(5221004, true, W::GUN, W::GUN) == FITS, + "held shooters refuse quietly"); + + require(check(1311001, false, W::SWORD_2H, W::SPEAR) == EQUIPPED && check(1311001, false, W::SPEAR, W::SPEAR) == FITS, + "the rest go by Skill.img"); + require(check(5001001, false, W::GUN, W::NONE) == FITS && check(5101002, false, W::GUN, W::NONE) == EQUIPPED, + "with v83's own exceptions"); + require(check(2001005, false, W::NONE, W::NONE) == FITS, "a skill that names no weapon takes any"); + + std::cout << "Skill weapon passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 56fefc448..f4a95f3d6 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -120,6 +120,25 @@ server tracks neither Shadow Partner, which doubles the cost, nor Soul Arrow's buff, nor the cash ammo slot. HeavenClient, like v83, fires from the first fitting stack that covers the cost (`Inventory::find_bulletslot`). +HeavenClient refuses a skill before sending it, with v83's lines in the skill +chat colour (`Player::can_use`, `Skill::can_use`, `Combat::can_use_move`). The +checks run in v83's order. A skill cooling down comes first. Then come the +costs: HP, MP, mesos (`moneyCon`; Shadow Meso's grows by Shadow Partner's `y` +percent while that buff is on), and the item (`itemCon`, named in the line). +The four cost lines share one two-second throttle, so a second refusal within +two seconds prints nothing. An attack with no weapon says so next. Then the +weapon (`skill_weapon::check`). A shot needs its job's weapon whatever +`Skill.img` says: a bow or crossbow for archers, a claw for thieves, a gun for +pirates, nothing for anyone else, with eight exceptions such as Taunt and +Energy Orb. Brandish needs a sword or an axe, Corkscrew Blow a knuckle, +Grenade a gun, and Poison Bomb a claw. These say "You cannot use this skill +with the weapon you're equipped with." The held shots (Hurricane, Piercing +Arrow, Rapid Fire) refuse anything but a bow, crossbow, or gun without a word. +Any other mismatch with the weapon `Skill.img` names, or the knuckle, sword, +or wand rules a few pirate and Cygnus skills have instead, says "This skill +cannot be used with the equipped item." Ammo comes last. RustMS charges a +special move's `itemCon` but never Shadow Meso's mesos. + HeavenClient picks targets as v83 does (`Combat::find_targets`). Melee and magic attacks take the mobs whose body overlaps the attack range in the order they entered the map (`MapObjects::entry_order`), not the closest ones, so a From 3193cfe9bf249e2b5da37672d12b4545f7cadc0b Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 01:16:45 -0400 Subject: [PATCH 12/23] L182: word messenger invite results as v83 does The messenger's invite result and invite reply now use v83's lines: "- You have sent the invite to 'X'.", "- 'X' can't be found.", and, by the reply's flag byte, "'X' denied the request." or "'X' is currently not accepting chat." As in v83 (FUN_00851293, FUN_00851387), both go into an open messenger's log and are dropped when it is closed, rather than falling back to a red chat line. --- HeavenClient/Net/Handlers/MessagingHandlers.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index 2448d3f34..90282465e 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -531,25 +531,27 @@ namespace ms break; } + // v83 writes both results into an open messenger's log and drops + // them otherwise (FUN_00851293, FUN_00851387). case Mode::INVITE_RESULT: { std::string target = recv.read_string(); bool sent = recv.read_bool(); - if (messenger) - messenger->add_note(sent ? "You have sent an invite to " + target + "." - : "Unable to find '" + target + "'."); + if (messenger && messenger->is_active()) + messenger->add_note(sent ? "- You have sent the invite to '" + target + "'." + : "- '" + target + "' can't be found."); break; } case Mode::DECLINED: { - std::string note = recv.read_string() + " has declined your invitation."; + std::string target = recv.read_string(); + bool blocking = recv.read_bool(); if (messenger && messenger->is_active()) - messenger->add_note(note); - else if (auto chatbar = UI::get().get_element()) - chatbar->send_chatline(note, UIChatBar::LineType::RED); + messenger->add_note(blocking ? "'" + target + "' is currently not accepting chat." + : "'" + target + "' denied the request."); break; } From 9505e4ace4efd68eeda58bc7f5e48ef775a7eb25 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 01:30:28 -0400 Subject: [PATCH 13/23] L182: refuse item use and drops the map forbids A map whose fieldLimit has bit 0x400 lets the player use only the items its info/allowedItem lists, and bit 0x400000 keeps items and mesos off the ground. HeavenClient now says v83's lines for both in the skill chat colour: "This item cannot be used here." for potions and the like, from the inventory or a quick slot, and "You cannot drop the item here." once the drop's questions are answered (v83 FUN_00a092fb, FUN_00a0900a, FUN_00a23de5). Dropping an item on the ground and equipping now check v83's slot gate first: "You can't do it right now. Catch your breath and then try again." while alert, then "You can't do it once you're dead." Double-clicking any item while dead says the latter. Equipping used to show a different alert line. --- HeavenClient/CMakeLists.txt | 2 + HeavenClient/Character/Player.cpp | 3 + HeavenClient/Gameplay/MapleMap/FieldLimit.h | 46 +++++++++++++ HeavenClient/Gameplay/MapleMap/MapInfo.cpp | 16 +++++ HeavenClient/Gameplay/MapleMap/MapInfo.h | 11 ++- HeavenClient/Gameplay/Stage.cpp | 5 ++ HeavenClient/Gameplay/Stage.h | 3 + HeavenClient/IO/Messages.cpp | 4 +- HeavenClient/IO/Messages.h | 3 + HeavenClient/IO/UITypes/UIItemInventory.cpp | 76 +++++++++++++++++---- HeavenClient/tests/field_limit.cpp | 38 +++++++++++ agent_docs/inventory.md | 17 +++++ 12 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 HeavenClient/Gameplay/MapleMap/FieldLimit.h create mode 100644 HeavenClient/tests/field_limit.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 1104dd50b..3bbbd9836 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -97,6 +97,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME buff_row COMMAND buff_row_tests) add_executable(item_drop_tests tests/item_drop.cpp) add_test(NAME item_drop COMMAND item_drop_tests) + add_executable(field_limit_tests tests/field_limit.cpp) + add_test(NAME field_limit COMMAND field_limit_tests) add_executable(scroll_rules_tests tests/scroll_rules.cpp) add_test(NAME scroll_rules COMMAND scroll_rules_tests) add_executable(pickup_tests tests/pickup.cpp) diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 2a2ba909c..aee6e3ccb 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -26,6 +26,7 @@ #include "../Data/WeaponData.h" #include "../Gameplay/Combat/MagicDamage.h" #include "../Gameplay/Stage.h" +#include "../IO/Messages.h" #include "../IO/UI.h" #include "../IO/UITypes/UIRevive.h" @@ -206,6 +207,8 @@ namespace ms { if (type == InventoryType::Id::USE && (itemid / 10000 == 228 || itemid / 10000 == 229)) request(SkillBookPacket(slot, itemid), ExclRequest::USE_WAIT); + else if (type == InventoryType::Id::USE && !Stage::get().get_mapinfo().can_use_item(itemid)) + InChatMessage(Messages::ITEM_FIELD).drop(); else if (type == InventoryType::Id::USE && disease::can_use_item(get_diseases())) request(UseItemPacket(slot, itemid), ExclRequest::USE_WAIT); else if (type == InventoryType::Id::SETUP && itemid / 10000 == 301) diff --git a/HeavenClient/Gameplay/MapleMap/FieldLimit.h b/HeavenClient/Gameplay/MapleMap/FieldLimit.h new file mode 100644 index 000000000..c1260bf39 --- /dev/null +++ b/HeavenClient/Gameplay/MapleMap/FieldLimit.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +namespace ms +{ + // What a map's fieldLimit stops the v83 client doing with items. + namespace field_limit + { + constexpr int32_t CONSUMABLES = 0x400; + constexpr int32_t DROPS = 0x400000; + + // v83 FUN_00a092fb: with bit 0x400, only the items the map lists + // under info/allowedItem can be used. The check covers the kinds + // that path sends (potions, food, cures, transformations and the + // like); return scrolls, scrolls and the rest go other ways. + inline bool may_use(int32_t fieldlimit, const std::vector &allowed, int32_t item_id) + { + switch (item_id / 10000) + { + case 200: + case 201: + case 202: + case 205: + case 221: + case 236: + case 238: + case 245: + break; + default: + return true; + } + + return !(fieldlimit & CONSUMABLES) || std::find(allowed.begin(), allowed.end(), item_id) != allowed.end(); + } + + // v83 FUN_00a0900a and FUN_00a23de5: bit 0x400000 keeps items and + // mesos off the ground. + inline bool may_drop(int32_t fieldlimit) + { + return !(fieldlimit & DROPS); + } + } +} diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp index c7f5bb7aa..789398c89 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp @@ -17,6 +17,8 @@ ////////////////////////////////////////////////////////////////////////////////// #include "MapInfo.h" +#include "FieldLimit.h" + #include "../Constants.h" #include @@ -49,6 +51,10 @@ namespace ms cloud = info["cloud"].get_bool(); fieldlimit = info["fieldLimit"]; + + for (auto item : info["allowedItem"]) + allowed_items.push_back(item); + hideminimap = info["hideMinimap"].get_bool(); mapmark = std::string(info["mapMark"]); swim = info["swim"].get_bool(); @@ -105,6 +111,16 @@ namespace ms return (fieldlimit & 0x2) == 0; } + bool MapInfo::can_use_item(int32_t item_id) const + { + return field_limit::may_use(fieldlimit, allowed_items, item_id); + } + + bool MapInfo::can_drop() const + { + return field_limit::may_drop(fieldlimit); + } + std::string MapInfo::get_bgm() const { return bgm; diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.h b/HeavenClient/Gameplay/MapleMap/MapInfo.h index 1ae9f5379..77d88d69c 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.h +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.h @@ -95,6 +95,13 @@ namespace ms // Whether the map lets the player use Teleport (fieldLimit bit 0x2). bool can_use_movement_skills() const; + // Whether the map lets the player use a potion or the like + // (field_limit::may_use). + bool can_use_item(int32_t item_id) const; + + // Whether the map lets the player drop items and mesos. + bool can_drop() const; + Range get_walls() const; Range get_borders() const; @@ -106,7 +113,9 @@ namespace ms Optional findladder(Point a, Point b) const; private: - int32_t fieldlimit; + int32_t fieldlimit = 0; + // The items a map with fieldLimit bit 0x400 still allows. + std::vector allowed_items; bool cloud; std::string bgm; std::string mapdesc; diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index f8abbb7d1..b387ef69b 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -834,6 +834,11 @@ namespace ms return mapid; } + const MapInfo &Stage::get_mapinfo() const + { + return mapinfo; + } + void Stage::set_field_key(uint8_t key) { field_key = key; diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index 2e2650d7f..88f80a6c5 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -129,6 +129,9 @@ namespace ms // Returns the current map ID. int32_t get_mapid(); + // Returns the current map's info. + const MapInfo &get_mapinfo() const; + // Keeps the key the server gave the field, which map change and // portal script requests repeat. void set_field_key(uint8_t key); diff --git a/HeavenClient/IO/Messages.cpp b/HeavenClient/IO/Messages.cpp index bbc772e06..f5eb09fdf 100644 --- a/HeavenClient/IO/Messages.cpp +++ b/HeavenClient/IO/Messages.cpp @@ -42,7 +42,9 @@ namespace ms "You may only use this skill with a one-handed weapon equipped.", "You cannot use that skill on this map.", "You cannot use the Mystic Door Skill near the portal.", - "Since you are not using a weapon, you are unable to attack." + "Since you are not using a weapon, you are unable to attack.", + "This item cannot be used here.", + "You cannot drop the item here." }; bool Messages::is_cost(Type type) diff --git a/HeavenClient/IO/Messages.h b/HeavenClient/IO/Messages.h index d61e0a5ae..9e94b312c 100644 --- a/HeavenClient/IO/Messages.h +++ b/HeavenClient/IO/Messages.h @@ -50,6 +50,9 @@ namespace ms DOOR_PORTAL, // Attacking without a weapon ATTACK_NOWEAPON, + // The map forbids the item (fieldLimit) + ITEM_FIELD, + DROP_FIELD, LENGTH }; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 9f4dad925..3a4aa94ad 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -19,6 +19,7 @@ #include "UINotice.h" +#include "../Messages.h" #include "../UI.h" #include "../Components/AreaButton.h" @@ -62,6 +63,20 @@ namespace ms // The right end of the meso amount, which sits in a field ending at 137. constexpr Point MESO = Point(131, 260); constexpr Point FULL_MESO = Point(135, 260); + + // v83's notices 0x10a and 0x42 (FUN_004f5697). + constexpr const char *CATCH_BREATH = "You can't do it right now.\\nCatch your breath and then try again."; + constexpr const char *DEAD = "You can't do it once you're dead."; + + // v83 FUN_00a0900a: a map may keep items off the ground, which it + // says only once the player has answered every question. + void send_drop(InventoryType::Id tab, int16_t slot, int16_t qty) + { + if (Stage::get().get_mapinfo().can_drop()) + Stage::get().get_player().request(MoveItemPacket(tab, slot, 0, qty), ExclRequest::WAIT); + else + InChatMessage(Messages::DROP_FIELD).drop(); + } } UIItemInventory::UIItemInventory(const Inventory &invent) @@ -304,7 +319,10 @@ namespace ms { auto onenter = [](int32_t amount) { - Stage::get().get_player().request(MesoDropPacket(amount), ExclRequest::USE_WAIT); + if (Stage::get().get_mapinfo().can_drop()) + Stage::get().get_player().request(MesoDropPacket(amount), ExclRequest::USE_WAIT); + else + InChatMessage(Messages::DROP_FIELD).drop(); }; UI::get().emplace( @@ -329,6 +347,14 @@ namespace ms { if (int32_t item_id = inventory.get_item_id(tab, slot)) { + // v83 FUN_004efd25 refuses everything once the player is dead. + if (Stage::get().get_player().is_dead()) + { + UI::get().emplace(DEAD, [](bool) + {}); + return; + } + switch (tab) { case InventoryType::Id::EQUIP: @@ -337,7 +363,9 @@ namespace ms break; case InventoryType::Id::USE: - if (disease::can_use_item(Stage::get().get_player().get_diseases())) + if (!Stage::get().get_mapinfo().can_use_item(item_id)) + InChatMessage(Messages::ITEM_FIELD).drop(); + else if (disease::can_use_item(Stage::get().get_player().get_diseases())) Stage::get().get_player().request(UseItemPacket(slot, item_id), ExclRequest::USE_WAIT); break; @@ -589,13 +617,18 @@ namespace ms const Player &player = Stage::get().get_player(); const CharStats &stats = player.get_stats(); const CharLook &look = player.get_look(); - const bool alerted = look.get_alerted(); - if (alerted) + if (look.get_alerted()) { - UI::get().emplace("You cannot complete this action right now.\\nEvade the attack and try again.", - [](bool) - {}); + UI::get().emplace(CATCH_BREATH, [](bool) + {}); + return false; + } + + if (player.is_dead()) + { + UI::get().emplace(DEAD, [](bool) + {}); return false; } @@ -798,6 +831,24 @@ namespace ms constexpr const char *untradablemessage = "This item can't be taken back once\\nthrown away. Will you still drop it?"; constexpr const char *cashmessage = "You can't drop this item."; + const Player &player = Stage::get().get_player(); + + if (player.get_look().get_alerted()) + { + UI::get().emplace(CATCH_BREATH, [](bool) + {}); + + return; + } + + if (player.is_dead()) + { + UI::get().emplace(DEAD, [](bool) + {}); + + return; + } + if (item_drop::refused(item_id, cashitem)) { UI::get().emplace(cashmessage, [](bool) @@ -812,13 +863,13 @@ namespace ms { auto onenter = [tab, slot](int32_t qty) { - Stage::get().get_player().request(MoveItemPacket(tab, slot, 0, qty), ExclRequest::WAIT); + send_drop(tab, slot, qty); }; UI::get().emplace(dropmessage, onenter, max, max); } else { - Stage::get().get_player().request(MoveItemPacket(tab, slot, 0, 1), ExclRequest::WAIT); + send_drop(tab, slot, 1); } }; @@ -858,15 +909,14 @@ namespace ms if (player.get_look().get_alerted()) { - UI::get().emplace("You can't do it right now.\\nCatch your breath and then try again.", - [](bool) - {}); + UI::get().emplace(CATCH_BREATH, [](bool) + {}); break; } if (player.is_dead()) { - UI::get().emplace("You can't do it once you're dead.", [](bool) + UI::get().emplace(DEAD, [](bool) {}); break; } diff --git a/HeavenClient/tests/field_limit.cpp b/HeavenClient/tests/field_limit.cpp new file mode 100644 index 000000000..32acddf5b --- /dev/null +++ b/HeavenClient/tests/field_limit.cpp @@ -0,0 +1,38 @@ +#include "../Gameplay/MapleMap/FieldLimit.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms::field_limit; + + // The eleven maps that list items all have fieldLimit 0x526fc. + const std::vector allowed = { 2022539, 2109004 }; + + require(may_use(0, {}, 2000000), "a Red Potion works on an open map"); + require(!may_use(0x400, {}, 2000000), "a Red Potion is refused where consumables are limited"); + require(!may_use(0x526fc, allowed, 2000000), "an unlisted potion is refused"); + require(may_use(0x526fc, allowed, 2022539), "a listed item still works"); + require(may_use(0x400, {}, 2030000), "a Return Scroll doesn't go the potion way"); + require(may_use(0x400, {}, 2040000), "a scroll doesn't go the potion way"); + require(!may_use(0x400, {}, 2210000), "a transformation potion is refused"); + + require(may_drop(0x400), "consumable limits leave drops alone"); + require(!may_drop(0x400000), "bit 0x400000 keeps items on the player"); + + std::cout << "Field limit passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 119b22662..87782bf01 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -54,6 +54,14 @@ v83, starts at 10 and takes no less. The field places either kind where it last saw the player, and anyone may pick it up at once (ownership 2). Dead players can't drop. +A map whose `fieldLimit` has bit 0x400000 keeps items and mesos off the +ground. As in v83, HeavenClient asks its usual questions first and then says +"You cannot drop the item here." in the skill chat colour instead of sending +the drop. It also refuses an item drop while the player is alert from a hit +or an attack ("You can't do it right now. Catch your breath and then try +again.") or dead ("You can't do it once you're dead."). The server doesn't +check the map. + GATHER_ITEMS (`0x45`) and SORT_ITEMS (`0x46`) (tick, type) are the item window's Gather and Sort buttons. The rearrangement goes out as one INVENTORY_OPERATION of swaps (mode 2), then GATHER_ITEM_RESULT (`0x34`) or @@ -73,6 +81,15 @@ applies its `spec` (`hp`, `mp`, and percent `hpR`/`mpR`, capped at the maximums), consumes one, and answers with the inventory change and an HP/MP stat update. Dead players can't use items. +A map whose `fieldLimit` has bit 0x400 allows only the items its +`info/allowedItem` lists (eleven event maps list ten items). HeavenClient +says "This item cannot be used here." in the skill chat colour when the +player double-clicks or presses a quick slot for anything else of the kinds +v83 checks there: potions, food, cures and transformations +(`field_limit::may_use`). Return scrolls and the rest aren't checked, and +the server doesn't check the map. Double-clicking any item while dead says +"You can't do it once you're dead." + HEAL_OVER_TIME (`0x59`) is HeavenClient's natural recovery, which follows the v83 (MapleLegends) client's 30 ms recovery check. HP and MP keep separate timers, which restart whenever their conditions fail, and go out in separate From f72744e3e94f1b0ee6773f7ec7b8858c152f0602 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 01:35:32 -0400 Subject: [PATCH 14/23] L188: refuse item use and drops the map forbids on the server The client already refuses these (L182 #15), but nothing stopped a client that sent them anyway. The field template now keeps the map's info/allowedItem list next to its fieldLimit and answers may_use and may_drop the way v83 does: bit 0x400 allows only the listed items of the kinds USE_ITEM carries, and bit 0x400000 keeps items and mesos off the ground. USE_ITEM, item drops and meso drops refuse with an enable-actions stat update, as they already did for dead players. --- agent_docs/inventory.md | 12 +++-- game-data/src/lib.rs | 64 +++++++++++++++++++++++- net/src/packet/handle/world/item_move.rs | 20 ++++++++ net/src/packet/handle/world/meso_drop.rs | 19 +++++++ net/src/packet/handle/world/use_item.rs | 31 ++++++++++++ 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 87782bf01..0e4d48f24 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -59,8 +59,9 @@ ground. As in v83, HeavenClient asks its usual questions first and then says "You cannot drop the item here." in the skill chat colour instead of sending the drop. It also refuses an item drop while the player is alert from a hit or an attack ("You can't do it right now. Catch your breath and then try -again.") or dead ("You can't do it once you're dead."). The server doesn't -check the map. +again.") or dead ("You can't do it once you're dead."). The server refuses +item and meso drops on such a map too (`FieldTemplate::may_drop`) with an +enable-actions stat update. GATHER_ITEMS (`0x45`) and SORT_ITEMS (`0x46`) (tick, type) are the item window's Gather and Sort buttons. The rearrangement goes out as one @@ -86,9 +87,10 @@ A map whose `fieldLimit` has bit 0x400 allows only the items its says "This item cannot be used here." in the skill chat colour when the player double-clicks or presses a quick slot for anything else of the kinds v83 checks there: potions, food, cures and transformations -(`field_limit::may_use`). Return scrolls and the rest aren't checked, and -the server doesn't check the map. Double-clicking any item while dead says -"You can't do it once you're dead." +(`field_limit::may_use`). Return scrolls and the rest aren't checked. The +server applies the same rule (`FieldTemplate::may_use`) and answers a refused +USE_ITEM with an enable-actions stat update. Double-clicking any item while +dead says "You can't do it once you're dead." HEAL_OVER_TIME (`0x59`) is HeavenClient's natural recovery, which follows the v83 (MapleLegends) client's 30 ms recovery check. HP and MP keep separate diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index 9990bd73d..b1f416db4 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -22,6 +22,12 @@ pub const PORTAL_TYPE_START_POINT: i32 = 0; pub const PORTAL_TYPE_TOWN_DOOR: i32 = 6; /// `info/fieldLimit` bit forbidding Mystic Door (v83 FUN_00969bf9). pub const FIELD_LIMIT_MYSTIC_DOOR: i32 = 0x8; +/// `info/fieldLimit` bit leaving only the map's `info/allowedItem` usable +/// (v83 FUN_00a092fb). +pub const FIELD_LIMIT_CONSUMABLES: i32 = 0x400; +/// `info/fieldLimit` bit keeping items and mesos off the ground (v83 +/// FUN_00a0900a, FUN_00a23de5). +pub const FIELD_LIMIT_DROPS: i32 = 0x400000; #[derive(Debug, Error)] pub enum GameDataError { @@ -198,6 +204,8 @@ pub struct FieldTemplate { pub town: bool, /// `info/fieldLimit`: what the map forbids (see `FIELD_LIMIT_*`). pub field_limit: i32, + /// `info/allowedItem`: the items `FIELD_LIMIT_CONSUMABLES` still allows. + pub allowed_items: Vec, /// Whether the map has a wall clock (a `clock` node with a position and /// size, as stations do), which the server sets to the time of day. pub clock: bool, @@ -207,6 +215,22 @@ pub struct FieldTemplate { } impl FieldTemplate { + /// Whether a player may use `item_id` here. With + /// `FIELD_LIMIT_CONSUMABLES`, the potions, food, cures and the like that + /// v83 sends as USE_ITEM work only if `info/allowedItem` lists them. + pub fn may_use(&self, item_id: i32) -> bool { + !matches!( + item_id / 10_000, + 200 | 201 | 202 | 205 | 221 | 236 | 238 | 245 + ) || self.field_limit & FIELD_LIMIT_CONSUMABLES == 0 + || self.allowed_items.contains(&item_id) + } + + /// Whether players may drop items and mesos here. + pub fn may_drop(&self) -> bool { + self.field_limit & FIELD_LIMIT_DROPS == 0 + } + pub fn portals(&self) -> impl Iterator { self.portals_by_id.values() } @@ -340,12 +364,13 @@ fn build_field_template( .transpose()? .flatten() .unwrap_or(1.0); - let (town, field_limit) = match nx.child_by_name(map_node_idx, "info")? { + let (town, field_limit, allowed_items) = match nx.child_by_name(map_node_idx, "info")? { Some(info) => ( nx.int_child(info, "town")?.unwrap_or(0) != 0, nx.int_child(info, "fieldLimit")?.unwrap_or(0), + allowed_items(nx, info)?, ), - None => (false, 0), + None => (false, 0, Vec::new()), }; // v83 FUN_0052bc72 makes the clock only if all four are non-zero. let clock = match nx.child_by_name(map_node_idx, "clock")? { @@ -418,6 +443,7 @@ fn build_field_template( recovery, town, field_limit, + allowed_items, clock, footholds, portals_by_id, @@ -425,6 +451,20 @@ fn build_field_template( }) } +/// A map's `info/allowedItem` list. +fn allowed_items(nx: &NxDataFile, info: u32) -> Result, GameDataError> { + let Some(list) = nx.child_by_name(info, "allowedItem")? else { + return Ok(Vec::new()); + }; + let mut items = Vec::new(); + for child in nx.child_indices(list)? { + if let Some(item) = nx.int_child(list, &nx.node_name(child)?)? { + items.push(item); + } + } + Ok(items) +} + fn build_footholds( nx: &NxDataFile, map_node_idx: u32, @@ -888,6 +928,7 @@ mod tests { recovery: 1.0, town: false, field_limit: 0, + allowed_items: Vec::new(), clock: false, footholds: footholds::Footholds::default(), portals_by_id: BTreeMap::from([ @@ -955,6 +996,7 @@ mod tests { recovery: 1.0, town: false, field_limit: 0, + allowed_items: Vec::new(), clock: false, reactors: Vec::new(), footholds: footholds::Footholds::default(), @@ -973,6 +1015,24 @@ mod tests { assert_eq!(closest(950), Some(3)); } + #[test] + fn reads_what_maps_do_to_items() { + let Some(path) = test_assets::path("Map.nx") else { + return; + }; + let game_data = GameData::load_from_nx_map(&path).expect("load map nx"); + let field = |id| game_data.field(id).expect("field"); + + let event = field(109090300); + assert!(!event.may_use(2000000), "a Red Potion isn't listed"); + assert!(event.may_use(2022539), "a listed item works"); + assert!(event.may_use(2030000), "a Return Scroll isn't checked"); + assert!(event.may_drop()); + assert!(!field(910000001).may_drop(), "the Free Market rooms"); + let henesys = field(100000000); + assert!(henesys.may_use(2000000) && henesys.may_drop()); + } + #[test] fn loads_map_npcs_from_assets_map_nx() { let Some(path) = test_assets::path("Map.nx") else { diff --git a/net/src/packet/handle/world/item_move.rs b/net/src/packet/handle/world/item_move.rs index a946848e1..26a6b4cd3 100644 --- a/net/src/packet/handle/world/item_move.rs +++ b/net/src/packet/handle/world/item_move.rs @@ -77,6 +77,12 @@ fn drop_item( if player.stats.hp <= 0 { return Err("dead"); } + let field = crate::get_game_data() + .ok() + .and_then(|data| data.field(player.stats.map_id)); + if !field.is_none_or(|field| field.may_drop()) { + return Err("the map forbids drops"); + } let kind = InventoryType::from_i16(i16::from(kind)) .filter(|kind| *kind != InventoryType::Equipped) .ok_or("not a bag")?; @@ -255,6 +261,20 @@ mod tests { assert!(thrown(&handle(&mut player, drop_request(4, 1, 0, 1))).is_none()); } + #[test] + fn refuses_drops_where_the_map_forbids_them() { + if crate::get_game_data().is_err() { + return; + } + let mut player = crate::codec::stats::sample_player(); + player.stats.map_id = 910_000_001; + player + .inventory + .insert(InventoryType::Etc, 1, Item::stack(4000019, 2)); + assert!(thrown(&handle(&mut player, drop_request(4, 1, 0, 1))).is_none()); + assert_eq!(player.inventory.count(4000019), 2); + } + #[test] fn quest_items_vanish() { // Green Apple, a quest item in v83. diff --git a/net/src/packet/handle/world/meso_drop.rs b/net/src/packet/handle/world/meso_drop.rs index 1cc123097..b84883532 100644 --- a/net/src/packet/handle/world/meso_drop.rs +++ b/net/src/packet/handle/world/meso_drop.rs @@ -22,9 +22,13 @@ impl WorldHandler for MesoDropHandler { let amount = reader.read_int()?; let player = &mut *ctx.player; + let field = crate::get_game_data() + .ok() + .and_then(|data| data.field(player.stats.map_id)); if player.stats.hp <= 0 || !(MESO_DROP_MIN..=MESO_DROP_MAX).contains(&amount) || amount > player.meso + || !field.is_none_or(|field| field.may_drop()) { debug!(amount, meso = player.meso, "Meso drop refused"); // Unlock the client's item actions. @@ -101,4 +105,19 @@ mod tests { )); assert_eq!(player.meso, 30); } + + #[test] + fn refuses_drops_where_the_map_forbids_them() { + if crate::get_game_data().is_err() { + return; + } + let mut player = crate::codec::stats::sample_player(); + player.meso = 100; + player.stats.map_id = 910_000_001; + assert!(matches!( + drop_mesos(&mut player, 10).as_slice(), + [WorldAction::Reply(_)] + )); + assert_eq!(player.meso, 100); + } } diff --git a/net/src/packet/handle/world/use_item.rs b/net/src/packet/handle/world/use_item.rs index f3f276927..827670ddf 100644 --- a/net/src/packet/handle/world/use_item.rs +++ b/net/src/packet/handle/world/use_item.rs @@ -73,6 +73,12 @@ fn use_item(player: &mut Player, slot: i16, item_id: i32, now: Instant) -> Optio if player.stats.hp <= 0 || player.buffs.has(BuffStat::StopPotion) { return None; } + let field = crate::get_game_data() + .ok() + .and_then(|data| data.field(player.stats.map_id)); + if !field.is_none_or(|field| field.may_use(item_id)) { + return None; + } if player.inventory.get(InventoryType::Use, slot)?.item_id != item_id { return None; } @@ -345,6 +351,31 @@ mod tests { assert_eq!(player.stats.hp, 125); } + #[test] + fn maps_that_forbid_consumables_allow_only_their_list() { + if items::data().is_err() || crate::get_game_data().is_err() { + return; + } + let mut player = crate::codec::stats::sample_player(); + player.stats.max_hp = 500; + player.stats.hp = 100; + player + .inventory + .insert(InventoryType::Use, 3, Item::stack(2000000, 2)); + let now = std::time::Instant::now(); + player.stats.map_id = 913_040_000; + assert!(use_item(&mut player, 3, 2000000, now).is_none()); + assert_eq!(player.inventory.count(2000000), 2); + + // 109090300 forbids consumables but lists 2022539. + player.stats.map_id = 109_090_300; + assert!(use_item(&mut player, 3, 2000000, now).is_none()); + player + .inventory + .insert(InventoryType::Use, 4, Item::stack(2022539, 1)); + assert!(use_item(&mut player, 4, 2022539, now).is_some()); + } + #[test] fn a_pill_buffs_for_its_time_and_all_cure_cures() { if items::data().is_err() { From 08bd56ada3a44cb7954bf15dc7f703d17bd8ac5e Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 01:52:30 -0400 Subject: [PATCH 15/23] L182: show invitations as v83's fading cards and answer busy party invites Messenger invitations, buddy requests, trade requests and party invites now arrive as v83's FadeYesNo cards (UIInviteCard, formerly UITradeInvite): the kind's icon and heading over "from ''", O to accept and X to decline, fading out unanswered after three minutes without sending anything. A new card replaces one of its kind except for buddy requests. Cards and quest-complete notices share one cascade (FadePlace), each 5 px above the last, as v83's shared FadeYesNo list does. As in v83, a party invite that arrives while a party card is showing is answered with DENY_PARTY_REQUEST reason 0x16, and one carrying the party-search flag is accepted without a card. X on a party card sends 0x17 with the receiver's name, and X on a messenger card sends the v83 decline. The server reads reason 0x16 as busy: it tells the inviter the receiver is taking care of another invitation and keeps that invitation pending. --- HeavenClient/CMakeLists.txt | 4 +- HeavenClient/IO/Components/FadeNotice.h | 72 +++++++ HeavenClient/IO/UIElement.h | 2 +- HeavenClient/IO/UIStateGame.cpp | 6 +- HeavenClient/IO/UITypes/UIInviteCard.cpp | 188 ++++++++++++++++++ HeavenClient/IO/UITypes/UIInviteCard.h | 78 ++++++++ HeavenClient/IO/UITypes/UIQuestComplete.cpp | 29 +-- HeavenClient/IO/UITypes/UIQuestComplete.h | 10 +- HeavenClient/IO/UITypes/UITradeInvite.cpp | 161 --------------- HeavenClient/IO/UITypes/UITradeInvite.h | 63 ------ HeavenClient/Net/Handlers/BuddyHandlers.cpp | 9 +- .../Net/Handlers/MessagingHandlers.cpp | 25 ++- HeavenClient/Net/Handlers/PartyHandlers.cpp | 39 +++- .../Handlers/PlayerInteractionHandlers.cpp | 12 +- HeavenClient/Net/Packets/GameplayPackets.h | 17 +- HeavenClient/Net/Packets/MessagingPackets.h | 6 +- HeavenClient/tests/fade_notice.cpp | 20 ++ agent_docs/buddies.md | 7 +- agent_docs/client.md | 15 +- agent_docs/parties.md | 14 +- net/src/handler.rs | 3 + net/src/packet/handle/world/party.rs | 27 ++- runtime/src/actor/directory.rs | 20 +- runtime/src/actor/parties.rs | 12 +- 24 files changed, 537 insertions(+), 302 deletions(-) create mode 100644 HeavenClient/IO/UITypes/UIInviteCard.cpp create mode 100644 HeavenClient/IO/UITypes/UIInviteCard.h delete mode 100644 HeavenClient/IO/UITypes/UITradeInvite.cpp delete mode 100644 HeavenClient/IO/UITypes/UITradeInvite.h diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 3bbbd9836..7f0aa055f 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -576,8 +576,8 @@ add_executable(HeavenClient IO/UITypes/UIShop.h IO/UITypes/UIStorage.cpp IO/UITypes/UIStorage.h - IO/UITypes/UITradeInvite.cpp - IO/UITypes/UITradeInvite.h + IO/UITypes/UIInviteCard.cpp + IO/UITypes/UIInviteCard.h IO/UITypes/UITradingRoom.cpp IO/UITypes/UITradingRoom.h IO/UITypes/UIMonsterBook.cpp diff --git a/HeavenClient/IO/Components/FadeNotice.h b/HeavenClient/IO/Components/FadeNotice.h index cc47190b1..65e03f11b 100644 --- a/HeavenClient/IO/Components/FadeNotice.h +++ b/HeavenClient/IO/Components/FadeNotice.h @@ -2,9 +2,72 @@ #include #include +#include +#include namespace ms { + // A fading notice's place in v83's one line of them (CWvsContext+0x367c, + // counted by DAT_00bed710), shared by every kind: invitations and quest + // completions alike. Each sits above the ones made before it, and those + // after one that goes move down (FUN_0051f93d, FUN_0052459c). + class FadePlace + { + public: + FadePlace() : ticket(++last()) + { + line().push_back(ticket); + } + + FadePlace(FadePlace&& other) noexcept : ticket(std::exchange(other.ticket, 0)) {} + + FadePlace& operator=(FadePlace&& other) noexcept + { + if (this != &other) + { + leave(); + ticket = std::exchange(other.ticket, 0); + } + + return *this; + } + + ~FadePlace() + { + leave(); + } + + // How many notices made before this one are still there. + size_t index() const + { + const std::vector& tickets = line(); + + return std::find(tickets.begin(), tickets.end(), ticket) - tickets.begin(); + } + + private: + void leave() + { + std::vector& tickets = line(); + tickets.erase(std::remove(tickets.begin(), tickets.end(), ticket), tickets.end()); + } + + static std::vector& line() + { + static std::vector tickets; + return tickets; + } + + static uint32_t& last() + { + static uint32_t ticket = 0; + return ticket; + } + + // 0 once moved from. + uint32_t ticket; + }; + // The life of one of v83's fading Yes/No notices (FUN_0051fb72): it fades // in over a second, stays until its time from being made runs out or a // button closes it, then fades out over a second and is gone. A close asked @@ -13,9 +76,17 @@ namespace ms { public: static constexpr uint32_t FADE_IN = 1000, FADE_OUT = 1000; + // How far each notice sits above the one made before it. + static constexpr int16_t CASCADE = 5; explicit FadeNotice(uint32_t lifetime) : lifetime(lifetime) {} + // How far above the first notice's place this one sits. + int16_t raised() const + { + return static_cast(place.index() * CASCADE); + } + void update(uint32_t elapsed) { age += elapsed; @@ -80,5 +151,6 @@ namespace ms uint32_t age = 0, fade_start = 0; Phase phase = Phase::FADING_IN; bool closing = false; + FadePlace place; }; } diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index 489de0141..d7fb1591a 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -80,7 +80,7 @@ namespace ms MONSTERBOOK, REVIVE, TRADINGROOM, - TRADEINVITE, + INVITECARD, NUM_TYPES }; diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index a45d4c755..ac1d43597 100644 --- a/HeavenClient/IO/UIStateGame.cpp +++ b/HeavenClient/IO/UIStateGame.cpp @@ -41,7 +41,7 @@ #include "UITypes/UIStorage.h" #include "UITypes/UIStatusBar.h" #include "UITypes/UIStatusMessenger.h" -#include "UITypes/UITradeInvite.h" +#include "UITypes/UIInviteCard.h" #include "UITypes/UIUserList.h" #include "UITypes/UIWorldMap.h" @@ -104,7 +104,7 @@ namespace ms emplace(Stage::get().get_player().get_party(), stats); emplace(look, inventory); emplace(inventory); - emplace(); + emplace(); VWIDTH = Constants::Constants::get().get_viewwidth(); VHEIGHT = Constants::Constants::get().get_viewheight(); @@ -611,7 +611,7 @@ namespace ms UIElement::Type::NPCTALK, UIElement::Type::SHOP, UIElement::Type::STORAGE, - UIElement::Type::TRADEINVITE, + UIElement::Type::INVITECARD, UIElement::Type::REVIVE }; diff --git a/HeavenClient/IO/UITypes/UIInviteCard.cpp b/HeavenClient/IO/UITypes/UIInviteCard.cpp new file mode 100644 index 000000000..b2c10b838 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIInviteCard.cpp @@ -0,0 +1,188 @@ +#include "UIInviteCard.h" + +#include "../Components/MapleButton.h" + +#include "../../Audio/Audio.h" +#include "../../Constants.h" +#include "../../Util/NxFiles.h" + +#include + +#include + +namespace ms +{ + namespace + { + // Placed like the quest-complete notice (FUN_00523408). + constexpr int16_t LEFT = 464, ABOVE_BOTTOM = 97; + const Point OK_AT(174, 5), CANCEL_AT(174, 19); + // The icon's left edge and the band it is centred in (FUN_0052065d). + constexpr int16_t ICON_X = 6, ICON_BAND = 37; + // How far the "from" line sits below the heading. + constexpr int16_t LINE = 13; + // How long from appearing until it fades out unanswered. + constexpr uint32_t LIFETIME = 180000; + + struct Look + { + const char* icon; + const char* heading; + // v83's text position (FUN_0052065d), moved up to HC's text tops. + Point text; + }; + + const Look LOOKS[UIInviteCard::NUM_KINDS] = { + { "icon0", "An invitation", Point(24, 2) }, + { "icon1", "Buddy request", Point(24, 2) }, + { "icon2", "Trade request", Point(24, 2) }, + { "icon5", "Party invite", Point(26, 4) } + }; + } + + UIInviteCard::UIInviteCard() : UIElement(Point(), Point(WIDTH, 0)) + { + nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; + + background = fade["backgrnd"]; + + for (size_t kind = 0; kind < NUM_KINDS; kind++) + { + icons[kind] = fade[LOOKS[kind].icon]; + headings[kind] = Text(Text::A12M, Text::LEFT, Color::Name::WHITE, LOOKS[kind].heading); + } + + update_screen(Constants::Constants::get().get_viewwidth(), Constants::Constants::get().get_viewheight()); + } + + void UIInviteCard::draw(float alpha) const + { + for (const Notice& notice : notices) + { + Point at = position + offset(notice); + float opacity = notice.life.opacity(); + const Texture& icon = icons[notice.kind]; + Point text = LOOKS[notice.kind].text; + + background.draw(DrawArgument(at, opacity)); + icon.draw(DrawArgument(at + Point(ICON_X, (ICON_BAND - icon.get_dimensions().y()) / 2), opacity)); + headings[notice.kind].draw(DrawArgument(at + text, opacity)); + notice.from.draw(DrawArgument(at + text + Point(0, LINE), opacity)); + } + + // Only the top notice can be reached; the others' buttons lie under it. + if (!notices.empty()) + { + float opacity = notices.back().life.opacity(); + + for (auto& [id, button] : buttons) + static_cast(button.get())->set_opacity(opacity); + } + + UIElement::draw_buttons(alpha); + } + + void UIInviteCard::update() + { + bool changed = false; + + for (auto it = notices.begin(); it != notices.end();) + { + it->life.update(Constants::TIMESTEP); + + if (it->life.gone()) + { + it = notices.erase(it); + changed = true; + } + else + { + ++it; + } + } + + // Notices of other kinds coming and going move these too. + if (changed || (!notices.empty() && notices.back().life.raised() != buttons_raised)) + rebuild(); + + UIElement::update(); + } + + void UIInviteCard::add(Kind kind, const std::string& inviter, std::function answer) + { + if (kind != BUDDY) + notices.erase(std::remove_if(notices.begin(), notices.end(), [kind](const Notice& n) { return n.kind == kind; }), + notices.end()); + + Text from(Text::A12M, Text::LEFT, Color::Name::WHITE, "from '" + inviter + "'"); + + notices.push_back({ kind, std::move(from), std::move(answer), FadeNotice(LIFETIME) }); + Sound(Sound::Name::INVITE).play(); + + rebuild(); + } + + bool UIInviteCard::showing(Kind kind) const + { + return std::any_of(notices.begin(), notices.end(), [kind](const Notice& n) { return n.kind == kind; }); + } + + void UIInviteCard::rebuild() + { + buttons.clear(); + + nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; + + if (notices.empty()) + return; + + buttons_raised = notices.back().life.raised(); + Point at = offset(notices.back()); + + buttons[OK] = std::make_unique(fade["BtOK"], at + OK_AT); + buttons[CANCEL] = std::make_unique(fade["BtCancel"], at + CANCEL_AT); + } + + Point UIInviteCard::offset(const Notice& notice) const + { + return Point(0, -notice.life.raised()); + } + + void UIInviteCard::update_screen(int16_t, int16_t new_height) + { + position = Point(LEFT, new_height - ABOVE_BOTTOM); + } + + bool UIInviteCard::is_in_range(Point cursorpos) const + { + if (notices.empty()) + return false; + + Point top = position + offset(notices.back()); + Point bottom = position + offset(notices.front()) + Point(WIDTH, HEIGHT); + + return Rectangle(top, bottom).contains(cursorpos); + } + + Button::State UIInviteCard::button_pressed(uint16_t buttonid) + { + // One press can arrive twice, and a notice fading out ignores more; + // act on the first. + if (notices.empty() || notices.back().life.closed()) + return Button::State::NORMAL; + + Notice& notice = notices.back(); + notice.life.close(); + + // The answer may add a notice; let it work on its own copy. + std::function answer = notice.answer; + answer(buttonid == OK); + + return Button::State::NORMAL; + } + + UIElement::Type UIInviteCard::get_type() const + { + return TYPE; + } +} diff --git a/HeavenClient/IO/UITypes/UIInviteCard.h b/HeavenClient/IO/UITypes/UIInviteCard.h new file mode 100644 index 000000000..e6072ce22 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIInviteCard.h @@ -0,0 +1,78 @@ +#pragma once + +#include "../UIElement.h" +#include "../Components/FadeNotice.h" + +#include "../../Graphics/Text.h" +#include "../../Graphics/Texture.h" + +#include +#include +#include +#include +#include + +namespace ms +{ + // v83's fading invitations above the status bar (UIWindow.img/FadeYesNo, + // FUN_0052065d): an invitation to Maple Messenger, a buddy request, a + // trade request or a party invite, each "from ''". O accepts and X + // turns it down; one left alone fades out after three minutes and sends + // nothing. A new one replaces any of its kind still there, except buddy + // requests, which pile up (FUN_00a247cf). Newer notices stack above older + // ones, quest completions included. + class UIInviteCard : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::INVITECARD; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + // v83's notice types 0, 1, 2 and 5. + enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, NUM_KINDS }; + + UIInviteCard(); + + void draw(float inter) const override; + void update() override; + void update_screen(int16_t new_width, int16_t new_height) override; + bool is_in_range(Point cursorpos) const override; + + UIElement::Type get_type() const override; + + // `answer` hears true for O and false for X. + void add(Kind kind, const std::string& inviter, std::function answer); + // Whether a notice of `kind` is still there, even fading out. + bool showing(Kind kind) const; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + enum Buttons : uint16_t { OK, CANCEL }; + + struct Notice + { + Kind kind; + Text from; + std::function answer; + FadeNotice life; + }; + + void rebuild(); + // From the bottom of the stack to `notice`. + Point offset(const Notice& notice) const; + + Texture background; + std::array icons; + std::array headings; + // Oldest first; the newest is drawn last, over the others. + std::vector notices; + // How high the buttons sit, to move them when the stack shifts. + int16_t buttons_raised = -1; + + // The window v83 makes for each (FUN_0051f9ad), on the same layer as + // dialogs, so a dialog opened later covers it. + static constexpr int16_t WIDTH = 196, HEIGHT = 44; + }; +} diff --git a/HeavenClient/IO/UITypes/UIQuestComplete.cpp b/HeavenClient/IO/UITypes/UIQuestComplete.cpp index 3dc593ad7..ad3fde93a 100644 --- a/HeavenClient/IO/UITypes/UIQuestComplete.cpp +++ b/HeavenClient/IO/UITypes/UIQuestComplete.cpp @@ -19,9 +19,9 @@ namespace ms namespace { // The first notice's left edge and how far its top sits above the - // bottom of the screen; each later one sits 5 px higher, over the last - // (FUN_00523408). The text is laid out from footage (FRS4wNJWWIE, 2:00). - constexpr int16_t LEFT = 464, ABOVE_BOTTOM = 97, CASCADE = 5; + // bottom of the screen; later ones sit higher, over it (FUN_00523408, + // FadePlace). The text is laid out from footage (FRS4wNJWWIE, 2:00). + constexpr int16_t LEFT = 464, ABOVE_BOTTOM = 97; constexpr int16_t TEXT_X = 24, NAME_WIDTH = 104; const Point ICON(6, 12), HEADING(TEXT_X, 2), NAME(TEXT_X, 15); const Point OK_AT(133, 5), CANCEL_AT(133, 19); @@ -43,15 +43,15 @@ namespace ms void UIQuestComplete::draw(float alpha) const { - for (size_t i = 0; i < notices.size(); i++) + for (const Notice& notice : notices) { - Point at = position + offset(i); - float opacity = notices[i].life.opacity(); + Point at = position + offset(notice); + float opacity = notice.life.opacity(); background.draw(DrawArgument(at, opacity)); icon.draw(DrawArgument(at + ICON, opacity)); heading.draw(DrawArgument(at + HEADING, opacity)); - notices[i].name.draw(DrawArgument(at + NAME, opacity)); + notice.name.draw(DrawArgument(at + NAME, opacity)); } // Only the top notice can be reached; the others' buttons lie under it. @@ -108,7 +108,8 @@ namespace ms changed = true; } - if (changed) + // Notices of other kinds coming and going move these too. + if (changed || (!notices.empty() && notices.back().life.raised() != buttons_raised)) rebuild(); UIElement::update(); @@ -123,15 +124,16 @@ namespace ms if (notices.empty()) return; - Point at = offset(notices.size() - 1); + buttons_raised = notices.back().life.raised(); + Point at = offset(notices.back()); buttons[OK] = std::make_unique(fade["BtOK"], at + OK_AT); buttons[CANCEL] = std::make_unique(fade["BtCancel"], at + CANCEL_AT); } - Point UIQuestComplete::offset(size_t index) const + Point UIQuestComplete::offset(const Notice& notice) const { - return Point(0, -static_cast(index * CASCADE)); + return Point(0, -notice.life.raised()); } void UIQuestComplete::update_screen(int16_t, int16_t new_height) @@ -144,9 +146,10 @@ namespace ms if (notices.empty()) return false; - Point top = position + offset(notices.size() - 1); + Point top = position + offset(notices.back()); + Point bottom = position + offset(notices.front()) + Point(WIDTH, HEIGHT); - return Rectangle(top, position + Point(WIDTH, HEIGHT)).contains(cursorpos); + return Rectangle(top, bottom).contains(cursorpos); } Button::State UIQuestComplete::button_pressed(uint16_t buttonid) diff --git a/HeavenClient/IO/UITypes/UIQuestComplete.h b/HeavenClient/IO/UITypes/UIQuestComplete.h index 2aef9f107..c8e691eae 100644 --- a/HeavenClient/IO/UITypes/UIQuestComplete.h +++ b/HeavenClient/IO/UITypes/UIQuestComplete.h @@ -18,7 +18,7 @@ namespace ms // above the status bar, shown when a quest's last objective is met. O // opens the quest in the quest log and X dismisses it. Each notice stays // until then, or until the quest is no longer in progress; newer notices - // stack above older ones. + // stack above older ones, invitations included. class UIQuestComplete : public UIElement { public: @@ -50,16 +50,18 @@ namespace ms }; void rebuild(); - // From the first notice to the one at `index`. - Point offset(size_t index) const; + // From the bottom of the stack to `notice`. + Point offset(const Notice& notice) const; QuestLog& quests; const Inventory& inventory; QuestCompletion completion; Texture background, icon; Text heading; - // Oldest first; the newest is drawn lowest. + // Oldest first; the newest is drawn last, over the others. std::vector notices; + // How high the buttons sit, to move them when the stack shifts. + int16_t buttons_raised = -1; static constexpr int16_t WIDTH = 155, HEIGHT = 44; }; diff --git a/HeavenClient/IO/UITypes/UITradeInvite.cpp b/HeavenClient/IO/UITypes/UITradeInvite.cpp deleted file mode 100644 index 2174997fd..000000000 --- a/HeavenClient/IO/UITypes/UITradeInvite.cpp +++ /dev/null @@ -1,161 +0,0 @@ -#include "UITradeInvite.h" - -#include "../Components/MapleButton.h" - -#include "../../Audio/Audio.h" -#include "../../Constants.h" -#include "../../Net/Packets/MiniRoomPackets.h" -#include "../../Util/NxFiles.h" - -#include - -namespace ms -{ - namespace - { - // Placed and stacked like the quest-complete notice (FUN_00523408), - // with v83's text positions (FUN_0052065d) moved up to HC's text tops. - constexpr int16_t LEFT = 464, ABOVE_BOTTOM = 97, CASCADE = 5; - const Point ICON(6, 9), HEADING(24, 2), FROM(24, 15); - const Point OK_AT(174, 5), CANCEL_AT(174, 19); - // How long from appearing until it fades out unanswered. - constexpr uint32_t LIFETIME = 180000; - } - - UITradeInvite::UITradeInvite() : UIElement(Point(), Point(WIDTH, 0)) - { - nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; - - background = fade["backgrnd"]; - icon = fade["icon2"]; - heading = Text(Text::A12M, Text::LEFT, Color::Name::WHITE, "Trade request"); - - update_screen(Constants::Constants::get().get_viewwidth(), Constants::Constants::get().get_viewheight()); - } - - void UITradeInvite::draw(float alpha) const - { - for (size_t i = 0; i < notices.size(); i++) - { - Point at = position + offset(i); - float opacity = notices[i].life.opacity(); - - background.draw(DrawArgument(at, opacity)); - icon.draw(DrawArgument(at + ICON, opacity)); - heading.draw(DrawArgument(at + HEADING, opacity)); - notices[i].from.draw(DrawArgument(at + FROM, opacity)); - } - - // Only the top notice can be reached; the others' buttons lie under it. - if (!notices.empty()) - { - float opacity = notices.back().life.opacity(); - - for (auto& [id, button] : buttons) - static_cast(button.get())->set_opacity(opacity); - } - - UIElement::draw_buttons(alpha); - } - - void UITradeInvite::update() - { - bool changed = false; - - for (auto it = notices.begin(); it != notices.end();) - { - it->life.update(Constants::TIMESTEP); - - // Running out turns the invite down, as X does (FUN_0051fff5). - if (!it->answered && it->life.closed()) - { - it->answered = true; - MiniRoomDeclinePacket(it->serial, MiniRoomPacket::Decline::DECLINED).dispatch(); - } - - if (it->life.gone()) - { - it = notices.erase(it); - changed = true; - } - else - { - ++it; - } - } - - if (changed) - rebuild(); - - UIElement::update(); - } - - void UITradeInvite::add(const std::string& inviter, int32_t serial) - { - Text from(Text::A12M, Text::LEFT, Color::Name::WHITE, "from '" + inviter + "'"); - - notices.push_back({ serial, std::move(from), FadeNotice(LIFETIME), false }); - Sound(Sound::Name::INVITE).play(); - - rebuild(); - } - - void UITradeInvite::rebuild() - { - buttons.clear(); - - nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; - - if (notices.empty()) - return; - - Point at = offset(notices.size() - 1); - - buttons[OK] = std::make_unique(fade["BtOK"], at + OK_AT); - buttons[CANCEL] = std::make_unique(fade["BtCancel"], at + CANCEL_AT); - } - - Point UITradeInvite::offset(size_t index) const - { - return Point(0, -static_cast(index * CASCADE)); - } - - void UITradeInvite::update_screen(int16_t, int16_t new_height) - { - position = Point(LEFT, new_height - ABOVE_BOTTOM); - } - - bool UITradeInvite::is_in_range(Point cursorpos) const - { - if (notices.empty()) - return false; - - Point top = position + offset(notices.size() - 1); - - return Rectangle(top, position + Point(WIDTH, HEIGHT)).contains(cursorpos); - } - - Button::State UITradeInvite::button_pressed(uint16_t buttonid) - { - // One press can arrive twice, and a notice fading out ignores more; - // act on the first. - if (notices.empty() || notices.back().life.closed()) - return Button::State::NORMAL; - - Notice& notice = notices.back(); - notice.life.close(); - notice.answered = true; - - if (buttonid == OK) - MiniRoomVisitPacket(notice.serial).dispatch(); - else - MiniRoomDeclinePacket(notice.serial, MiniRoomPacket::Decline::DECLINED).dispatch(); - - return Button::State::NORMAL; - } - - UIElement::Type UITradeInvite::get_type() const - { - return TYPE; - } -} diff --git a/HeavenClient/IO/UITypes/UITradeInvite.h b/HeavenClient/IO/UITypes/UITradeInvite.h deleted file mode 100644 index e31f82c37..000000000 --- a/HeavenClient/IO/UITypes/UITradeInvite.h +++ /dev/null @@ -1,63 +0,0 @@ -#pragma once - -#include "../UIElement.h" -#include "../Components/FadeNotice.h" - -#include "../../Graphics/Text.h" -#include "../../Graphics/Texture.h" - -#include -#include -#include - -namespace ms -{ - // v83's fading "Trade request" notice (UIWindow.img/FadeYesNo/backgrnd, - // FUN_00522f14) above the status bar, shown when someone invites us to - // trade. O joins their trading room; X, or leaving it until it fades out, - // turns the invite down. Newer notices stack above older ones. - class UITradeInvite : public UIElement - { - public: - static constexpr Type TYPE = UIElement::Type::TRADEINVITE; - static constexpr bool FOCUSED = false; - static constexpr bool TOGGLED = false; - - UITradeInvite(); - - void draw(float inter) const override; - void update() override; - void update_screen(int16_t new_width, int16_t new_height) override; - bool is_in_range(Point cursorpos) const override; - - UIElement::Type get_type() const override; - - void add(const std::string& inviter, int32_t serial); - - protected: - Button::State button_pressed(uint16_t buttonid) override; - - private: - enum Buttons : uint16_t { OK, CANCEL }; - - struct Notice - { - int32_t serial; - Text from; - FadeNotice life; - // Pressed, or turned down when it ran out. - bool answered; - }; - - void rebuild(); - // From the first notice to the one at `index`. - Point offset(size_t index) const; - - Texture background, icon; - Text heading; - // Oldest first; the newest is drawn last, over the others. - std::vector notices; - - static constexpr int16_t WIDTH = 195, HEIGHT = 44; - }; -} diff --git a/HeavenClient/Net/Handlers/BuddyHandlers.cpp b/HeavenClient/Net/Handlers/BuddyHandlers.cpp index eec3c9bcd..efddc8e14 100644 --- a/HeavenClient/Net/Handlers/BuddyHandlers.cpp +++ b/HeavenClient/Net/Handlers/BuddyHandlers.cpp @@ -4,6 +4,7 @@ #include "../../Gameplay/Stage.h" #include "../../IO/UI.h" +#include "../../IO/UITypes/UIInviteCard.h" #include "../../IO/UITypes/UINotice.h" namespace ms @@ -71,16 +72,14 @@ namespace ms int32_t id = recv.read_int(); std::string name = recv.read_string(); - UI::get().emplace( - "'" + name + "' wants to add you as a buddy.\\nWould you like to accept?", - [id](bool yes) + if (auto cards = UI::get().get_element()) + cards->add(UIInviteCard::BUDDY, name, [id](bool yes) { if (yes) AcceptBuddyPacket(id).dispatch(); else DeleteBuddyPacket(id).dispatch(); - } - ); + }); break; } case Operation::OWN_LIST_FULL: diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index 90282465e..0cdaeb1a9 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -32,6 +32,7 @@ #include "StatusMessages.h" #include "../../IO/UITypes/UIChatBar.h" +#include "../../IO/UITypes/UIInviteCard.h" #include "../../IO/UITypes/UIMessenger.h" #include "../../IO/UITypes/UINotice.h" #include "../../IO/UITypes/UIStatusMessenger.h" @@ -517,17 +518,19 @@ namespace ms std::string from = recv.read_string(); recv.skip_byte(); int32_t messenger_id = recv.read_int(); - - UI::get().emplace( - from + " has invited you to Maple Messenger. Would you like to join?", - [from, messenger_id](bool yes) - { - if (yes) - MessengerOpenPacket(messenger_id).dispatch(); - else - MessengerDeclinePacket(from).dispatch(); - } - ); + std::string self = Stage::get().get_player().get_stats().get_name(); + + // O does nothing while a messenger is already open (FUN_0084dd25). + if (auto cards = UI::get().get_element()) + cards->add(UIInviteCard::MESSENGER, from, [from, self, messenger_id](bool yes) + { + auto open = UI::get().get_element(); + + if (!yes) + MessengerDeclinePacket(from, self).dispatch(); + else if (!open || !open->is_active()) + MessengerOpenPacket(messenger_id).dispatch(); + }); break; } diff --git a/HeavenClient/Net/Handlers/PartyHandlers.cpp b/HeavenClient/Net/Handlers/PartyHandlers.cpp index 218d95fb2..13b2daec5 100644 --- a/HeavenClient/Net/Handlers/PartyHandlers.cpp +++ b/HeavenClient/Net/Handlers/PartyHandlers.cpp @@ -11,6 +11,7 @@ #include "../../Gameplay/Stage.h" #include "../../IO/UI.h" #include "../../IO/UITypes/UIChatBar.h" +#include "../../IO/UITypes/UIInviteCard.h" #include "../../IO/UITypes/UINotice.h" #include "../../Util/CurseWords.h" @@ -137,17 +138,35 @@ namespace ms { int32_t party_id = recv.read_int(); std::string inviter = recv.read_string(); + // Nonzero for a party search's invitation, which v83 accepts + // unseen (FUN_00a3e31c). + int8_t search = recv.read_byte(); + const std::string& self = player.get_stats().get_name(); - UI::get().emplace( - "'" + inviter + "' has invited you to a party.\\nWould you like to join?", - [party_id, inviter](bool yes) - { - if (yes) - JoinPartyPacket(party_id).dispatch(); - else - DenyPartyRequestPacket(inviter).dispatch(); - } - ); + if (search) + { + JoinPartyPacket(party_id, search).dispatch(); + break; + } + + auto cards = UI::get().get_element(); + + if (!cards) + break; + + if (cards->showing(UIInviteCard::PARTY)) + { + DenyPartyRequestPacket(DenyPartyRequestPacket::BUSY, inviter, self).dispatch(); + break; + } + + cards->add(UIInviteCard::PARTY, inviter, [party_id, inviter, self](bool yes) + { + if (yes) + JoinPartyPacket(party_id).dispatch(); + else + DenyPartyRequestPacket(DenyPartyRequestPacket::DENIED, inviter, self).dispatch(); + }); break; } case Operation::SILENT_UPDATE: diff --git a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp index 25f23d91d..3df45de31 100644 --- a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp @@ -26,7 +26,7 @@ #include "../../IO/UITypes/UICharInfo.h" #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UINotice.h" -#include "../../IO/UITypes/UITradeInvite.h" +#include "../../IO/UITypes/UIInviteCard.h" #include "../../IO/UITypes/UITradingRoom.h" #include "../Packets/MiniRoomPackets.h" @@ -213,8 +213,14 @@ namespace ms break; } - if (auto invite = UI::get().get_element()) - invite->add(inviter, serial); + if (auto cards = UI::get().get_element()) + cards->add(UIInviteCard::TRADE, inviter, [serial](bool yes) + { + if (yes) + MiniRoomVisitPacket(serial).dispatch(); + else + MiniRoomDeclinePacket(serial, MiniRoomPacket::Decline::DECLINED).dispatch(); + }); break; } diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 0c4f27fae..0b68976f5 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -313,13 +313,15 @@ namespace ms } }; - // Joins a party + // Joins a party. v83 follows the id with the invitation's party-search + // byte (FUN_00a3e31c, FUN_00522585). class JoinPartyPacket : public PartyOperationPacket { public: - JoinPartyPacket(int32_t party_id) : PartyOperationPacket(PartyOperationPacket::Operation::JOIN) + JoinPartyPacket(int32_t party_id, int8_t search = 0) : PartyOperationPacket(PartyOperationPacket::Operation::JOIN) { write_int(party_id); + write_byte(search); } }; @@ -353,17 +355,20 @@ namespace ms } }; - // Turns down a party invitation + // Turns down a party invitation: X on the invite (DENIED), or one that + // came while another was still showing (BUSY) (FUN_00a3e31c, FUN_0051fff5). // Opcode: DENY_PARTY_REQUEST(125) class DenyPartyRequestPacket : public OutPacket { public: - static constexpr int8_t DENIED = 0x17; + static constexpr int8_t BUSY = 0x16, DENIED = 0x17; - DenyPartyRequestPacket(const std::string& inviter) : OutPacket(OutPacket::Opcode::DENY_PARTY_REQUEST) + DenyPartyRequestPacket(int8_t reason, const std::string& inviter, const std::string& self) : + OutPacket(OutPacket::Opcode::DENY_PARTY_REQUEST) { - write_byte(DENIED); + write_byte(reason); write_string(inviter); + write_string(self); } }; diff --git a/HeavenClient/Net/Packets/MessagingPackets.h b/HeavenClient/Net/Packets/MessagingPackets.h index 275743ae1..f6af593b4 100644 --- a/HeavenClient/Net/Packets/MessagingPackets.h +++ b/HeavenClient/Net/Packets/MessagingPackets.h @@ -136,12 +136,16 @@ namespace ms } }; + // X on an invitation (FUN_0051fff5); v83 ends with 1 instead when its + // options turn invitations away unseen (FUN_00851478). class MessengerDeclinePacket : public MessengerPacket { public: - MessengerDeclinePacket(const std::string& inviter) : MessengerPacket(Mode::DECLINE) + MessengerDeclinePacket(const std::string& inviter, const std::string& self) : MessengerPacket(Mode::DECLINE) { write_string(inviter); + write_string(self); + write_byte(0); } }; diff --git a/HeavenClient/tests/fade_notice.cpp b/HeavenClient/tests/fade_notice.cpp index 9e61fa4d5..34a44ba3b 100644 --- a/HeavenClient/tests/fade_notice.cpp +++ b/HeavenClient/tests/fade_notice.cpp @@ -1,9 +1,29 @@ #include "../IO/Components/FadeNotice.h" #include +#include int main() { + // Every notice stacks above the ones made before it; when one goes, the + // later ones move down, and moving a notice keeps its place. + { + std::vector line; + line.emplace_back(1000); + line.emplace_back(1000); + line.emplace_back(1000); + ms::FadeNotice other(1000); + assert(line[0].raised() == 0 && line[2].raised() == 10 && other.raised() == 15); + + line.erase(line.begin() + 1); + assert(line[0].raised() == 0 && line[1].raised() == 5 && other.raised() == 10); + + line.erase(line.begin()); + assert(line[0].raised() == 0 && other.raised() == 5); + } + + assert(ms::FadeNotice(1000).raised() == 0); + // Untouched: a second in, until 30 s from being made, a second out. ms::FadeNotice notice(30000); assert(notice.opacity() == 0.0f); diff --git a/agent_docs/buddies.md b/agent_docs/buddies.md index f9ee44957..7e97b13ab 100644 --- a/agent_docs/buddies.md +++ b/agent_docs/buddies.md @@ -50,7 +50,8 @@ entry is the 39-byte `GW_Friend` record. Its channel is zero-based, or -1. - the list; - channel changes; - capacity; - - requests, shown as a `UIYesNo` (yes accepts, no declines); + - requests, shown as a fading notice (`UIInviteCard`, see + [client.md](client.md)); O accepts and X declines; - errors, shown as a `UIOk`. - `MultiChatHandler` prints buddy chat in orange. - The Friend tab of `UIUserList` is built from `UserList/Friend` art: @@ -75,6 +76,8 @@ entry is the 39-byte `GW_Friend` record. Its channel is zero-based, or -1. - Raising the capacity (packet 0x15 is parsed but never sent), and the Cash Shop flags. - The MAP button, notes, and the blacklist. +- v83 also records a request as a pending entry in the list, says so in the + chat log, and shows its notices again after a map change. ## Unverified against period footage @@ -84,5 +87,5 @@ from the art: - the two columns; - where names and channels sit in a row; - the button order; -- the request and delete wording; +- the delete wording; - the buddy chat colour (orange). diff --git a/agent_docs/client.md b/agent_docs/client.md index 18ca5f2f6..b17d13262 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -101,6 +101,16 @@ from `UI.nx`. Moved so far: [quests.md](quests.md). - **Quest Complete! notice** (`UIQuestComplete`, `UIWindow.img/FadeYesNo`). See [quests.md](quests.md). +- **Invitation notices** (`UIInviteCard`, `UIWindow.img/FadeYesNo`): v83's + fading cards above the status bar for Maple Messenger invitations, buddy + requests, trade requests, and party invites, each "from ''" beside + its kind's icon (FUN_0052065d). O accepts and X declines; a card left + alone fades out after three minutes and sends nothing. A new invitation + replaces a card of its kind, except buddy requests, which pile up. Each + card, quest completions included, sits 5 px above the one before it + (`FadeNotice::raised`), and only the newest can be clicked. Not done: the + cash trade card, holding cards back while the UI is locked, and the Game + Option and blacklist refusals. - **Minimap** (`UIMiniMap`, `UIWindow.img/MiniMap`): folded to its title bar, the map alone (`MinMap`), or the map under the map's mark, street, and name (`MaxMap`), laid out from period footage. M or the `Basic.img` minus and @@ -183,7 +193,8 @@ from `UI.nx`. Moved so far: opens a room, or minimises the open one to its title bar (which blinks on unread lines). Clicking an empty seat or typing `/invite name` invites; `/q` or the close button leaves. A speaker shows the `chatBalloon` dots for a - few seconds. Invitations arrive as a yes/no notice. + few seconds. Invitations arrive as an invitation notice, whose O does + nothing while a room is open. - **Game Option** (`UIGameOption`, `UIWindow.img/GameOpt`), from MENU: a `Basic.img/CheckBox` right of each row's ALLOW, laid out from the background. OK saves the boxes as the `Allow*` settings; the client doesn't @@ -442,7 +453,7 @@ side yet. | Monster Book (`UIMonsterBook`) | Implemented: cards, summary, monster pages, search, cover; see `combat.md` | | Cash shop (`UICashShop`) | Enter/exit, catalog, buying, gifts, Cash Inventory, wish list, and moving items to and from the bags | | Maple Messenger (`UIMessenger`) | Implemented | -| Trade (`UITradingRoom`, `UITradeInvite`) | Implemented, without cash trades | +| Trade (`UITradingRoom`, `UIInviteCard`) | Implemented, without cash trades | | Storage (`UIStorage`) | Implemented; see `inventory.md` | | Continent ships, station wall clocks | Docking ships and the Balrog's ship, CONTI_MOVE/CONTI_STATE, CLOCK kind 1; see `ships.md` | | Player shops, mini-games, memo/mail | No UI, no handlers | diff --git a/agent_docs/parties.md b/agent_docs/parties.md index 7d1ba1849..0624a0c7b 100644 --- a/agent_docs/parties.md +++ b/agent_docs/parties.md @@ -15,7 +15,10 @@ server restart. the client actor forwards to the directory. - Inviting someone without a party creates one first. An invitation keeps its receiver busy for `INVITE_TIMEOUT` (60 s); meanwhile, other parties' - invitations are refused with "taking care of another invitation". + invitations are refused with "taking care of another invitation". A + receiver whose client is still showing an invitation answers a second one + with `DENY_PARTY_REQUEST` reason 0x16 instead of 0x17; that refuses it the + same way and leaves the first one pending. - When the leader leaves, the party disbands. When anyone else leaves or is expelled, the party goes on. - The directory tracks each online character's `Presence`: name, job, @@ -58,7 +61,10 @@ reconstruction of v83: player's map. - `Net/Handlers/PartyHandlers` applies `PARTY_OPERATION`, `UPDATE_PARTYMEMBER_HP`, and party `MULTICHAT`. It prints the v83 chat-log - messages and shows invitations as a `UIYesNo`. + messages and shows invitations as a fading notice (`UIInviteCard`, see + [client.md](client.md)). As in v83, an invitation arriving while one is + shown is answered busy, and one with a nonzero party-search flag is + accepted without asking. - `UIUserList` is the v83 "Maple User List & Guild" window (`UIWindow.img/UserList`). Only the Party tab works so far. - Members are listed under online and offline. The leader has a star. @@ -90,9 +96,7 @@ reconstruction of v83: ## Unverified against period footage - The party chat colour (#FF99CC). -- The invite prompt's wording, and whether the leading tab in v83's - "has become the leader" line shows. +- Whether the leading tab in v83's "has become the leader" line shows. - The bottom button grid's exact pitch. -- v83 used a fading notice for invitations; this uses a plain yes/no box. - Whisper fills in `/w name` in the chat bar. In footage, v83 asked for the name in a dialog first. diff --git a/net/src/handler.rs b/net/src/handler.rs index 2e4387d4b..8e44a73de 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -200,8 +200,11 @@ pub enum PartyRequest { Invite { name: String, }, + /// Turn down `inviter`'s invitation. `busy` says another was still + /// showing, which leaves the invitation waiting for that one's answer. Decline { inviter: String, + busy: bool, }, Expel { member_id: i32, diff --git a/net/src/packet/handle/world/party.rs b/net/src/packet/handle/world/party.rs index a89ddb33f..07b86668a 100644 --- a/net/src/packet/handle/world/party.rs +++ b/net/src/packet/handle/world/party.rs @@ -8,6 +8,10 @@ use crate::packet::build::world::party::MULTICHAT_PARTY; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; +/// The `DENY_PARTY_REQUEST` reason for an invitation that came while +/// another was showing. +const DENY_BUSY: u8 = 0x16; + /// `PARTY_OPERATION`: create, leave, accept, invite, expel, or pass the lead. /// The world directory owns parties, so every operation goes there. pub struct PartyOperationHandler; @@ -37,18 +41,23 @@ impl WorldHandler for PartyOperationHandler { } } -/// `DENY_PARTY_REQUEST`: the receiver turned down an invitation. +/// `DENY_PARTY_REQUEST`: reason, inviter, receiver. The receiver turned +/// down an invitation (`0x17`), or v83 did for them because another was +/// still showing (`0x16`, FUN_00a3e31c). pub struct DenyPartyRequestHandler; impl WorldHandler for DenyPartyRequestHandler { fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { let mut reader = Cursor::new(&packet.bytes); reader.read_short()?; - reader.read_byte()?; + let busy = reader.read_byte()? == DENY_BUSY; let inviter = reader.read_str_with_length()?; // Party search invitations name the inviter as "PS: name". let inviter = inviter.strip_prefix("PS: ").unwrap_or(&inviter).to_string(); - Ok(HandlerResult::empty().with(WorldAction::Party(PartyRequest::Decline { inviter }))) + Ok( + HandlerResult::empty() + .with(WorldAction::Party(PartyRequest::Decline { inviter, busy })), + ) } } @@ -138,10 +147,20 @@ mod tests { .unwrap(); packet.write_byte(0x17).unwrap(); packet.write_str_with_length("PS: Alice").unwrap(); + packet.write_str_with_length("Bobby").unwrap(); + assert_eq!( + request(&DenyPartyRequestHandler, &packet.bytes), + Some(PartyRequest::Decline { + inviter: "Alice".into(), + busy: false, + }) + ); + packet.bytes[2] = 0x16; assert_eq!( request(&DenyPartyRequestHandler, &packet.bytes), Some(PartyRequest::Decline { - inviter: "Alice".into() + inviter: "Alice".into(), + busy: true, }) ); } diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index ae23808fb..2181fde0e 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -366,11 +366,15 @@ impl WorldDirectory { self.exchange_hp(from); } } - PartyRequest::Decline { inviter } => { + PartyRequest::Decline { inviter, busy } => { let Some(&inviter) = self.names.get(&inviter) else { return; }; - if self.parties.decline(from, inviter) { + if busy { + if self.parties.is_invited(from, inviter) { + self.send_status(inviter, PartyStatus::BusyWithInvite, &member.name); + } + } else if self.parties.decline(from, inviter) { self.send_status(inviter, PartyStatus::Declined, &member.name); } } @@ -842,11 +846,23 @@ mod tests { }, ); assert_eq!(received(&mut carol), ["op 0x4"]); + // Busy with another invitation: the inviter hears so, and the + // invitation still waits. + request( + &mut directory, + 3, + PartyRequest::Decline { + inviter: "Bobby".into(), + busy: true, + }, + ); + assert_eq!(received(&mut bobby), ["op 0x16"]); request( &mut directory, 3, PartyRequest::Decline { inviter: "Bobby".into(), + busy: false, }, ); assert_eq!(received(&mut bobby), ["op 0x17"]); diff --git a/runtime/src/actor/parties.rs b/runtime/src/actor/parties.rs index 0b301fec2..0a11aa8a8 100644 --- a/runtime/src/actor/parties.rs +++ b/runtime/src/actor/parties.rs @@ -128,12 +128,16 @@ impl Parties { Some(party) } + /// Whether `inviter`'s invitation to `invitee` is waiting. + pub fn is_invited(&self, invitee: ClientId, inviter: ClientId) -> bool { + self.invites + .get(&invitee) + .is_some_and(|invite| invite.inviter == inviter) + } + /// Turn down `inviter`'s invitation. Returns whether there was one. pub fn decline(&mut self, invitee: ClientId, inviter: ClientId) -> bool { - let pending = self - .invites - .get(&invitee) - .is_some_and(|invite| invite.inviter == inviter); + let pending = self.is_invited(invitee, inviter); if pending { self.invites.remove(&invitee); } From c35fa48b383a28b7f16fd4600f6aa0e4d8729163 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 02:10:35 -0400 Subject: [PATCH 16/23] L187: charge Shadow Meso's mesos and roll its damage as v83 does Shadow Meso now costs the mesos the v83 client checks for before a throw (FUN_00764256): its moneyCon, raised by Shadow Partner's y percent while Shadow Partner is up. The server refuses a throw the player can't afford, takes the mesos with the MP, and sends the new total in the same STAT_CHANGED. Its damage cap follows the client's roll (FUN_0078df87): ten times one and a half its moneyCon, with its x percent more on a critical. HeavenClient rolls Shadow Meso's damage the same way: between half and one and a half its moneyCon, floored and times ten, its own critical in place of the user's, a percent less for each level the mob is above the user, and no defence subtracted. --- HeavenClient/Data/SkillData.cpp | 1 + HeavenClient/Data/SkillData.h | 2 + HeavenClient/Gameplay/Combat/Attack.h | 6 +- HeavenClient/Gameplay/Combat/ShotPath.h | 17 +++++ HeavenClient/Gameplay/Combat/Skill.cpp | 9 +++ HeavenClient/Gameplay/MapleMap/Mob.cpp | 20 ++++-- HeavenClient/tests/shot_path.cpp | 3 + agent_docs/combat.md | 24 +++++-- game-data/src/skills.rs | 3 + net/src/combat.rs | 87 +++++++++++++++++++++++-- net/src/packet/handle/world/combat.rs | 11 +++- 11 files changed, 164 insertions(+), 19 deletions(-) diff --git a/HeavenClient/Data/SkillData.cpp b/HeavenClient/Data/SkillData.cpp index 34a82ae51..236d07858 100644 --- a/HeavenClient/Data/SkillData.cpp +++ b/HeavenClient/Data/SkillData.cpp @@ -79,6 +79,7 @@ namespace ms time, shotrange, y) ).first->second; + added.x = sub["x"]; added.mesocost = sub["moneyCon"]; added.itemcost = sub["itemCon"]; added.itemcount = sub["itemConNo"]; diff --git a/HeavenClient/Data/SkillData.h b/HeavenClient/Data/SkillData.h index 525cd4da7..43e97d056 100644 --- a/HeavenClient/Data/SkillData.h +++ b/HeavenClient/Data/SkillData.h @@ -60,6 +60,8 @@ namespace ms int16_t shotrange; // The skill's own value, as Element Amplification's damage percent. int32_t y; + // A second value of the skill's own, as Shadow Meso's critical. + int32_t x = 0; // The mesos the skill takes, and the item and how many of it. int32_t mesocost = 0; int32_t itemcost = 0; diff --git a/HeavenClient/Gameplay/Combat/Attack.h b/HeavenClient/Gameplay/Combat/Attack.h index 51838f78a..1bf8535fd 100644 --- a/HeavenClient/Gameplay/Combat/Attack.h +++ b/HeavenClient/Gameplay/Combat/Attack.h @@ -39,7 +39,9 @@ namespace ms { DMG_WEAPON, DMG_MAGIC, - DMG_FIXED + DMG_FIXED, + // Shadow Meso's, from the mesos it throws. + DMG_MESO }; Type type = Type::CLOSE; @@ -52,6 +54,8 @@ namespace ms int32_t matk = 0; int32_t accuracy = 0; int32_t fixdamage = 0; + // Shadow Meso's `x`, what a critical adds in percent. + int32_t mesocritical = 0; int16_t playerlevel = 1; // What a spell's damage comes from: the player's magic, INT and LUK, // the spell's mastery and element, and Element Amplification's percent. diff --git a/HeavenClient/Gameplay/Combat/ShotPath.h b/HeavenClient/Gameplay/Combat/ShotPath.h index f629e0567..ae7b539d6 100644 --- a/HeavenClient/Gameplay/Combat/ShotPath.h +++ b/HeavenClient/Gameplay/Combat/ShotPath.h @@ -208,6 +208,23 @@ namespace ms return scale; } + // A Shadow Meso hit (FUN_0078df87): ten times the whole mesos of a + // throw, `roll` between half and one and a half its `moneyCon`, with + // its `x` percent more on a critical, then a percent less for each + // level the mob is above the user. + inline double meso_damage(double roll, bool critical, int32_t x, int16_t leveldelta) + { + double damage = std::floor(roll) * 10; + + if (critical) + damage = (100 + x) * damage * 0.01; + + if (leveldelta > 0) + damage = (100 - leveldelta) * damage * 0.01; + + return damage; + } + // The area a shot covers: its row from `origin` to `end_x`, widened // for some skills, or the skill's own `lt_rb` around the `user`, // turned the way they face. diff --git a/HeavenClient/Gameplay/Combat/Skill.cpp b/HeavenClient/Gameplay/Combat/Skill.cpp index 2bb654a48..014dd0d15 100644 --- a/HeavenClient/Gameplay/Combat/Skill.cpp +++ b/HeavenClient/Gameplay/Combat/Skill.cpp @@ -245,6 +245,15 @@ namespace ms attack.element = SkillData::get(skillid).get_element(); attack.elemboost = elemboost(user, attack.element); attack.damagetype = Attack::DMG_MAGIC; + } else if (skillid == SkillId::SHADOW_MESO) + { + // The mesos it throws, with its own critical in place of the + // user's (FUN_0078df87). + attack.mindamage = stats.mesocost * 0.5; + attack.maxdamage = stats.mesocost * 1.5; + attack.critical = stats.chance; + attack.mesocritical = stats.x; + attack.damagetype = Attack::DMG_MESO; } else if (stats.matk) { attack.matk += stats.matk; diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index 4e359a7fe..ac3614816 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -23,6 +23,7 @@ #include "../Combat/HitPoint.h" #include "../Combat/MonsterMagnet.h" #include "../Combat/Prepare.h" +#include "../Combat/ShotPath.h" #include "../../Character/SkillId.h" #include "../../Constants.h" #include "../../Util/Misc.h" @@ -2062,6 +2063,13 @@ namespace ms hitchance = 1.0f; critical = 0.0f; break; + // Shadow Meso misses as a blow does, but goes through defense. + case Attack::DamageType::DMG_MESO: + mindamage = attack.mindamage; + maxdamage = attack.maxdamage; + hitchance = calculate_hitchance(leveldelta, attack.accuracy); + critical = attack.critical; + break; } std::generate( @@ -2080,9 +2088,10 @@ namespace ms { using namespace magic_damage; - // A blow meets the same statuses a spell does (FUN_0078df87), but - // fixed damage skips them. - bool weapon = attack.damagetype == Attack::DamageType::DMG_WEAPON; + // A blow or Shadow Meso meets the same statuses a spell does + // (FUN_0078df87), but fixed damage skips them. + bool weapon = attack.damagetype == Attack::DamageType::DMG_WEAPON || + attack.damagetype == Attack::DamageType::DMG_MESO; if (weapon && status_value(MobStatParser::DISABLE)) return std::pair(0, false); @@ -2100,7 +2109,10 @@ namespace ms double damage = randomizer.next_real(mindamage, maxdamage); bool iscritical = randomizer.below(critical); - if (iscritical) + if (attack.damagetype == Attack::DamageType::DMG_MESO) + damage = shot_path::meso_damage(damage, iscritical, attack.mesocritical, + static_cast(std::max(level - attack.playerlevel, 0))); + else if (iscritical) damage *= 1.5; // Sacrifice goes through a Power Guard Up. diff --git a/HeavenClient/tests/shot_path.cpp b/HeavenClient/tests/shot_path.cpp index f94b61097..6b0ec6ea3 100644 --- a/HeavenClient/tests/shot_path.cpp +++ b/HeavenClient/tests/shot_path.cpp @@ -138,6 +138,9 @@ int main() require(damage_scale(3001004, 150, 0) == 1.5f && damage_scale(0, 0, 0) == 1.0f, "a skill hits for its damage percent, all of it with none"); require(damage_scale(3101005, 0, 130) == 1.3f, "Arrow Bomb for its x percent"); + require(meso_damage(855.9, false, 50, 0) == 8550.0, "Shadow Meso hits for ten times the whole mesos"); + require(meso_damage(855.9, true, 50, 0) == 12825.0, "and its x percent more on a critical"); + require(meso_damage(855.9, false, 50, 10) == 7695.0, "and a percent less per level the mob is above"); require(!area_shot(15111006) && !area_shot(3101005) && !area_shot(0), "Spark, Arrow Bomb and plain shots don't"); require(hides_bullet(3111004) && !hides_bullet(5221008) && !hides_bullet(4111005), "Arrow Rain shows no arrow"); diff --git a/agent_docs/combat.md b/agent_docs/combat.md index f4a95f3d6..d0f3f63b2 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -95,8 +95,12 @@ shot's `bulletCount`, otherwise `attackCount`), the MP spent, and the cap: a `fixdamage` skill (Three Snails: 10/25/40) hits for at most that, a `mad` spell for twice v83's magic maximum (`max_magic_damage`: ((M² / 1000 + M) / 30 + INT / 200) × `mad`, where M is INT plus equipment magic attack), a `damage` -skill for that percentage of the basic cap, and a skill with none is not an -attack. A spell must name a skill. Too little MP refuses the attack. +skill for that percentage of the basic cap, Shadow Meso for v83's highest +roll (ten times one and a half its `moneyCon`, raised by its `x` percent on a +critical), and a skill with none is not an attack. A spell must name a skill. +Too little MP refuses the attack, as do too few mesos for the skill's +`moneyCon` (Shadow Meso's, raised by Shadow Partner's `y` percent while that +buff is on, as the v83 client checks). HeavenClient sends a skill as the attack v83's `CUserLocal::DoActiveSkill` makes it, whatever the weapon: a swing, a shot, or a spell (`SkillData::flags_of`). Any other skill goes out as SPECIAL_MOVE. Skills @@ -106,7 +110,8 @@ The field shows the attack under its own opcode (with the skill id and level, so others see the skill's effect, and for a shot the star, arrow, or bullet it fired) to the others, applies the damage, sends the attacker SHOW_MONSTER_HP, and on death -KILL_MONSTER to everyone; the attacker gets the MP left as STAT_CHANGED. +KILL_MONSTER to everyone; the attacker gets the MP and mesos left as +STAT_CHANGED. A shot fires from the USE slot the client names, as in v83 (`shot_ammo`). The item there must fit the weapon (stars for claws, arrows for bows and the @@ -137,7 +142,15 @@ Arrow, Rapid Fire) refuse anything but a bow, crossbow, or gun without a word. Any other mismatch with the weapon `Skill.img` names, or the knuckle, sword, or wand rules a few pirate and Cygnus skills have instead, says "This skill cannot be used with the equipped item." Ammo comes last. RustMS charges a -special move's `itemCon` but never Shadow Meso's mesos. +special move's `itemCon`, and Shadow Meso exactly the mesos the client checks +for. The skill's description gives each level a range around its +`moneyCon`, so v83's server may have charged a varying amount. + +HeavenClient rolls Shadow Meso's hits as v83 does (`shot_path::meso_damage`, +FUN_0078df87): ten times a whole number of mesos between half and one and a +half its `moneyCon`, with its own `prop` and `x` for a critical in place of +the user's, then a percent less for each level the mob is above the user. It +misses as a blow does but ignores the mob's defense. HeavenClient picks targets as v83 does (`Combat::find_targets`). Melee and magic attacks take the mobs whose body overlaps the attack range in the order @@ -841,7 +854,8 @@ Setting restores the same table. `field_mobs` unit tests cover spawning, control hand-off (including by distance report), movement, attacks' MP, damage, EXP sharing, and respawn; -`net::combat` covers parsing, the damage cap, and the broadcast, and the +`net::combat` covers parsing, the damage cap, Shadow Meso's mesos, and the +broadcast, and the TAKE_DAMAGE handler's bounds by attack index; `game::mob_damage` covers the StandardPDD table and the bound. HeavenClient's `mob_damage_tests` and `mob_attack_tests` cover the damage and miss formulas and the attacks' diff --git a/game-data/src/skills.rs b/game-data/src/skills.rs index 7d2ac04a0..6a170a71e 100644 --- a/game-data/src/skills.rs +++ b/game-data/src/skills.rs @@ -72,6 +72,8 @@ pub struct SkillLevel { /// Shadow Partner's Summoning Rock. pub item_cost: i32, pub item_cost_count: i32, + /// Mesos each use costs (`moneyCon`), as Shadow Meso. + pub money_cost: i32, /// The skill's area around the user facing left (`lt`, `rb`), (0, 0) /// for one without. pub lt: (i32, i32), @@ -168,6 +170,7 @@ impl SkillData { prop: int("prop")?, item_cost: int("itemCon")?, item_cost_count: int("itemConNo")?, + money_cost: int("moneyCon")?, lt: nx.vector_child(level, "lt")?.unwrap_or_default(), rb: nx.vector_child(level, "rb")?.unwrap_or_default(), }); diff --git a/net/src/combat.rs b/net/src/combat.rs index b592fa4e4..0287c24e0 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -40,6 +40,11 @@ const ARROW_BOMB: i32 = 3101005; /// Rapid Fire's targets end without the usual 4 bytes. const RAPID_FIRE: i32 = 5221004; +/// Shadow Meso, which throws mesos, and the Shadow Partner that raises +/// what it costs. +const SHADOW_MESO: i32 = 4111004; +const SHADOW_PARTNER: i32 = 4111002; + /// Skills sent as shots that fire no ammo (the v83 client's FUN_007667ee). const AMMO_FREE: [i32; 12] = [ 4111004, // Shadow Meso @@ -228,12 +233,13 @@ pub struct Ammo { pub quantity: i16, } -/// What a valid attack uses: its skill, whose MP the caller spends, and a -/// shot's ammo, which the caller takes. +/// What a valid attack uses: its skill, whose MP the caller spends, a +/// shot's ammo, which the caller takes, and the mesos it throws. #[derive(Clone, Debug, PartialEq)] pub struct Uses { pub skill: Option, pub ammo: Option, + pub mesos: i32, } /// A shot fires the stack in the slot the client names, which the weapon @@ -283,6 +289,7 @@ pub enum AttackRejection { /// A skill the player hasn't learned, or one that doesn't attack. Skill, NotEnoughMp, + NotEnoughMesos, TooManyTargets, Damage, /// A shot without enough of an ammo its weapon fires. @@ -300,11 +307,24 @@ pub fn attack_mp_cost(skill: &Learned) -> i32 { } } +/// The mesos a skill's attack throws: its `moneyCon`, raised by Shadow +/// Partner's `y` percent while Shadow Partner is up (the v83 client's +/// FUN_00764256). +fn meso_cost(skill: &Learned, player: &Player) -> i32 { + let cost = skill.effect.money_cost; + if player.buffs.value(BuffStat::ShadowPartner).is_none() { + return cost; + } + let raise = skills::learned(player, SHADOW_PARTNER).map_or(0, |partner| partner.effect.y); + cost + raise * cost / 100 +} + /// Basic attacks hit one mob once; each hit must stay under twice the base /// maximum (room for the client's own rolls, never a multiple of it). A /// skill attack takes its limits from the skill's level: its mob and hit -/// counts (a shot's bullets), and its fixed damage, its magic attack, or its -/// damage percent. A spell must be a skill, and a shot needs ammo. +/// counts (a shot's bullets), and its fixed damage, its magic attack, its +/// damage percent, or the mesos it throws. A spell must be a skill, and a +/// shot needs ammo. pub fn validate(attack: &Attack, player: &Player) -> Result { if player.stats.hp <= 0 { return Err(AttackRejection::Dead); @@ -319,6 +339,7 @@ pub fn validate(attack: &Attack, player: &Player) -> Result Result Result effect.bullet_count, _ => effect.attack_count, @@ -381,7 +410,7 @@ pub fn validate(attack: &Attack, player: &Player) -> Result Player { let mut player = beginner_with_sword(); player.inventory.insert( @@ -904,7 +976,8 @@ mod tests { slot: 1, item_id: 2070000, quantity: 1 - }) + }), + mesos: 0 } ); // Arrows don't fit a claw, and an empty slot holds nothing. diff --git a/net/src/packet/handle/world/combat.rs b/net/src/packet/handle/world/combat.rs index 350f2d3a3..30e6c0dd8 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -117,8 +117,15 @@ impl WorldHandler for AttackHandler { }) }); if let Some(skill) = uses.skill { - let mp = ctx.player.add_mp(-combat::attack_mp_cost(&skill)); - result = HandlerResult::reply(stat::build_stat_update(false, &[StatUpdate::Mp(mp)])?); + let mut updates = vec![StatUpdate::Mp( + ctx.player.add_mp(-combat::attack_mp_cost(&skill)), + )]; + if uses.mesos > 0 { + ctx.player.meso -= uses.mesos; + ctx.player.mark_dirty(); + updates.push(StatUpdate::Meso(ctx.player.meso)); + } + result = HandlerResult::reply(stat::build_stat_update(false, &updates)?); skill_level = skill.level as u8; } let mut projectile = 0; From d9261d66989f6c5974e0b7e917a0222e3c92925f Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 02:33:08 -0400 Subject: [PATCH 17/23] L181: open v83's menu on another character Releasing the right button over someone else now opens UIWindow.img's ContextMenu at the cursor, pushed back inside the view, with their name in the top cap. The hit box is v83's (FUN_0045183b): 44 by 65 above the feet, or 46 by 31 toward the head when prone. The rows are Personal Info, Save Name, Trade Request, Whisper, Party Invite for leaders and the partyless, Guild Invite for masters and Jr. Masters, and Alert GM; rows that don't apply are left out. The menu is modal and closes before it acts, and a press or right release outside, ESC or Enter closes it. Trade's checks move from the info window to UITradingRoom::request so both share them. Guild Invite runs v83's /guildinvite checks before sending GUILD_OPERATION 5, which the server doesn't handle yet. Alert GM shows the maintenance notice v83 gives when the server has reports off. The right button now acts on release everywhere, as v83 does. --- HeavenClient/CMakeLists.txt | 6 + HeavenClient/Gameplay/MapleMap/CharClick.h | 22 ++ HeavenClient/Gameplay/Stage.cpp | 20 ++ HeavenClient/Gameplay/Stage.h | 3 + .../IO/Components/ContextMenuLayout.h | 76 +++++++ HeavenClient/IO/UIElement.h | 1 + HeavenClient/IO/UIStateGame.cpp | 14 +- HeavenClient/IO/UITypes/UICharInfo.cpp | 27 +-- HeavenClient/IO/UITypes/UICharInfo.h | 1 - HeavenClient/IO/UITypes/UIContextMenu.cpp | 206 ++++++++++++++++++ HeavenClient/IO/UITypes/UIContextMenu.h | 48 ++++ HeavenClient/IO/UITypes/UITradingRoom.cpp | 23 ++ HeavenClient/IO/UITypes/UITradingRoom.h | 5 + HeavenClient/IO/Window.cpp | 4 +- HeavenClient/Net/Packets/GameplayPackets.h | 13 +- HeavenClient/tests/char_click.cpp | 14 ++ HeavenClient/tests/context_menu.cpp | 42 ++++ agent_docs/client.md | 19 ++ 18 files changed, 514 insertions(+), 30 deletions(-) create mode 100644 HeavenClient/IO/Components/ContextMenuLayout.h create mode 100644 HeavenClient/IO/UITypes/UIContextMenu.cpp create mode 100644 HeavenClient/IO/UITypes/UIContextMenu.h create mode 100644 HeavenClient/tests/context_menu.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 7f0aa055f..9aa9c5fdc 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -197,6 +197,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(char_click_tests tests/char_click.cpp) target_include_directories(char_click_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME char_click COMMAND char_click_tests) + add_executable(context_menu_tests tests/context_menu.cpp) + target_include_directories(context_menu_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME context_menu COMMAND context_menu_tests) add_executable(excl_request_tests tests/excl_request.cpp) add_test(NAME excl_request COMMAND excl_request_tests) add_executable(portal_touch_tests tests/portal_touch.cpp Gameplay/MapleMap/Portal.cpp) @@ -477,6 +480,7 @@ add_executable(HeavenClient IO/Components/SliderSteps.h IO/Components/EquipTooltip.cpp IO/Components/EquipTooltip.h + IO/Components/ContextMenuLayout.h IO/Components/FadeNotice.h IO/Components/Gauge.cpp IO/Components/Gauge.h @@ -532,6 +536,8 @@ add_executable(HeavenClient IO/UITypes/UIGameOption.h IO/UITypes/UICharInfo.cpp IO/UITypes/UICharInfo.h + IO/UITypes/UIContextMenu.cpp + IO/UITypes/UIContextMenu.h IO/UITypes/UICharCreation.cpp IO/UITypes/UICharCreation.h IO/UITypes/UICharSelect.cpp diff --git a/HeavenClient/Gameplay/MapleMap/CharClick.h b/HeavenClient/Gameplay/MapleMap/CharClick.h index 6e0fdf552..259ae3504 100644 --- a/HeavenClient/Gameplay/MapleMap/CharClick.h +++ b/HeavenClient/Gameplay/MapleMap/CharClick.h @@ -18,5 +18,27 @@ namespace ms { return std::abs(cursor.x() - feet.x()) <= HALF_WIDTH && cursor.y() <= feet.y() && cursor.y() >= feet.y() - HEIGHT; } + + // v83's box for opening the menu on someone (FUN_0045183b): 44 wide + // and 65 tall above the feet, or 46 by 31 lying prone, toward the + // head. Left and top edges are in, right and bottom ones out. + inline bool menu_hits(Point feet, bool prone, bool facing_right, Point cursor) + { + int16_t left = prone ? -46 : -22; + int16_t right = prone ? 0 : 22; + int16_t top = prone ? -31 : -65; + + if (facing_right) + { + int16_t mirrored = -right; + right = -left; + left = mirrored; + } + + int16_t x = cursor.x() - feet.x(); + int16_t y = cursor.y() - feet.y(); + + return x >= left && x < right && y >= top && y < 0; + } } } diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index b387ef69b..08d30bcc0 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -728,6 +728,26 @@ namespace ms return 0; } + int32_t Stage::other_character_at(Point cursorpos) + { + if (state != State::ACTIVE) + return 0; + + Point viewpos = camera.position(); + + // v83 hit-tests only the others (FUN_009726ea). + for (auto &mmo : *chars.get_chars()) + { + Optional other = chars.get_char(mmo.first); + + if (other && other->is_active() + && char_click::menu_hits(other->get_position() + viewpos, other->is_prone(), other->getflip(), cursorpos)) + return other->get_oid(); + } + + return 0; + } + void Stage::release_key(KeyType::Id type, int32_t action) { if (state != State::ACTIVE) diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index 88f80a6c5..9f12c7252 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -82,6 +82,9 @@ namespace ms // The character under a cursor position, or 0 if there is none. int32_t character_at(Point cursorpos); + // Someone else under a cursor position, by v83's box for opening the + // menu on them, or 0. The player can't be picked. + int32_t other_character_at(Point cursorpos); // Lets go of a held key whose release went elsewhere, such as to a // dialog or a text field, so the player doesn't keep walking. diff --git a/HeavenClient/IO/Components/ContextMenuLayout.h b/HeavenClient/IO/Components/ContextMenuLayout.h new file mode 100644 index 000000000..2474cd3b8 --- /dev/null +++ b/HeavenClient/IO/Components/ContextMenuLayout.h @@ -0,0 +1,76 @@ +#pragma once + +#include "../../Template/Point.h" + +#include +#include + +namespace ms +{ + // The rows of v83's menu on someone else (FUN_009967fb) and where it + // opens (FUN_00996570). + namespace context_menu + { + // In display order. v83's button ids are 0x7d0 on from these; it has + // a handler for a cash item trade, but no row for it. + enum Entry : uint16_t + { + INFO, + SAVE_NAME, + TRADE, + CASH_TRADE, + WHISPER, + PARTY, + GUILD, + CLAIM, + FAMILY + }; + + constexpr int16_t WIDTH = 100; + constexpr int16_t ROW = 15; + // The top cap's height, where the first row starts. + constexpr int16_t TOP = 23; + + // A row is left out when its condition fails, and those below move + // up; none is ever greyed. + inline std::vector entries(bool party_invite, bool guild_invite, bool add_junior) + { + std::vector rows = { INFO, SAVE_NAME, TRADE, WHISPER }; + + if (party_invite) + rows.push_back(PARTY); + + if (guild_invite) + rows.push_back(GUILD); + + rows.push_back(CLAIM); + + if (add_junior) + rows.push_back(FAMILY); + + return rows; + } + + // The caps and the rows. + inline int16_t height(size_t rows) + { + return static_cast((rows + 2) * ROW); + } + + // The top-left corner sits at the cursor, moved back inside the view + // if the menu would leave it at the right or the bottom. + inline Point place(Point cursor, size_t rows, int16_t view_width, int16_t view_height) + { + int16_t x = cursor.x(); + int16_t y = cursor.y(); + + if (x + WIDTH > view_width) + x = view_width - WIDTH; + + if (y + height(rows) > view_height) + y = view_height - height(rows); + + return Point(x, y); + } + } +} diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index d7fb1591a..a4d5e2409 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -81,6 +81,7 @@ namespace ms REVIVE, TRADINGROOM, INVITECARD, + CONTEXTMENU, NUM_TYPES }; diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index ac1d43597..973d361e5 100644 --- a/HeavenClient/IO/UIStateGame.cpp +++ b/HeavenClient/IO/UIStateGame.cpp @@ -25,6 +25,7 @@ #include "UITypes/UIQuestHelper.h" #include "UITypes/UIChannel.h" #include "UITypes/UICharInfo.h" +#include "UITypes/UIContextMenu.h" #include "UITypes/UIChatBar.h" #include "UITypes/UIEquipInventory.h" #include "UITypes/UIItemInventory.h" @@ -226,8 +227,18 @@ namespace ms void UIStateGame::rightclick(Point pos) { - if (UIElement *front = get_front(pos)) + // A window holding the mouse, such as the menu, hears it alone. + if (UIElement *focusedelement = get(focused); focusedelement && focusedelement->is_active()) + focusedelement->rightclick(pos); + else if (UIElement *front = get_front(pos)) front->rightclick(pos); + // Releasing the button over someone else opens the menu on them + // (FUN_0095009a). + else if (int32_t cid = Stage::get().other_character_at(pos)) + { + if (Optional other = Stage::get().get_chars().get_char(cid)) + emplace(cid, other->get_name(), pos); + } } void UIStateGame::send_key(KeyType::Id type, int32_t action, bool pressed, bool escape) @@ -612,6 +623,7 @@ namespace ms UIElement::Type::SHOP, UIElement::Type::STORAGE, UIElement::Type::INVITECARD, + UIElement::Type::CONTEXTMENU, UIElement::Type::REVIVE }; diff --git a/HeavenClient/IO/UITypes/UICharInfo.cpp b/HeavenClient/IO/UITypes/UICharInfo.cpp index 60f6c4b22..27a61afde 100644 --- a/HeavenClient/IO/UITypes/UICharInfo.cpp +++ b/HeavenClient/IO/UITypes/UICharInfo.cpp @@ -24,7 +24,6 @@ #include "../../IO/UI.h" #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UITradingRoom.h" -#include "../../Net/Packets/MiniRoomPackets.h" #include "../../Net/Packets/PlayerInteractionPackets.h" #include "../../Util/NxFiles.h" @@ -175,7 +174,7 @@ namespace ms give_fame(false); break; case Buttons::BT_TRADE: - start_trade(); + UITradingRoom::request(cid); break; case Buttons::BT_PET_SHOW: set_mode(Mode::PET); @@ -256,30 +255,6 @@ namespace ms GiveFamePacket(cid, up).dispatch(); } - void UICharInfo::start_trade() const - { - auto room = UI::get().get_element(); - const char *refusal = nullptr; - - // As v83 (FUN_0052fad4): one room at a time, with someone else on - // the map. - if (room && room->is_active()) - refusal = "Not available"; - else if (Stage::get().is_player(cid) || !Stage::get().get_character(cid)) - refusal = "Unable to find the character."; - - if (refusal) - { - if (auto chatbar = UI::get().get_element()) - chatbar->send_chatline(refusal, UIChatBar::LineType::SKILL); - - return; - } - - TradeCreatePacket().dispatch(); - MiniRoomInvitePacket(cid).dispatch(); - } - void UICharInfo::set_mode(Mode m) { mode = m; diff --git a/HeavenClient/IO/UITypes/UICharInfo.h b/HeavenClient/IO/UITypes/UICharInfo.h index d594d4e16..c07aae1f9 100644 --- a/HeavenClient/IO/UITypes/UICharInfo.h +++ b/HeavenClient/IO/UITypes/UICharInfo.h @@ -53,7 +53,6 @@ namespace ms private: void give_fame(bool up) const; - void start_trade() const; // The main window alone, or lengthened to show pets, the mount, or // the monster book collection. diff --git a/HeavenClient/IO/UITypes/UIContextMenu.cpp b/HeavenClient/IO/UITypes/UIContextMenu.cpp new file mode 100644 index 000000000..1bf2a03ba --- /dev/null +++ b/HeavenClient/IO/UITypes/UIContextMenu.cpp @@ -0,0 +1,206 @@ +#include "UIContextMenu.h" + +#include "UIChatBar.h" +#include "UICharInfo.h" +#include "UINotice.h" +#include "UITradingRoom.h" + +#include "../PartyCommands.h" +#include "../UI.h" +#include "../Window.h" + +#include "../Components/ContextMenuLayout.h" +#include "../Components/MapleButton.h" + +#include "../../Constants.h" +#include "../../Gameplay/Stage.h" +#include "../../Net/Packets/GameplayPackets.h" +#include "../../Net/Packets/PlayerInteractionPackets.h" +#include "../../Util/Misc.h" +#include "../../Util/NxFiles.h" + +#include + +namespace ms +{ + namespace + { + // The rows' art, by context_menu::Entry. + const char* const BUTTONS[] = { + "BtInfo", "BtSave", "BtTrade", "", "BtWhisper", "BtParty", "BtGuild", "BtClaim", "BtFamily" + }; + + // The rows' buttons, against the menu's top-left corner. + constexpr int16_t BUTTON_X = 9, BUTTON_Y = 24; + // The middle strips and the bottom cap. + constexpr int16_t STRIP_X = 3; + // The target's name in the top cap, cut to fit. v83 puts the text's + // top at (11, 6); HC's glyphs sit about 9 lower than where it draws. + const Point TITLE_AT(11, -3); + constexpr size_t TITLE_WIDTH = 78; + + void notice(const std::string& line) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(line, UIChatBar::LineType::SKILL); + } + + // Juniors come from the family, which HC doesn't have. + bool can_add_junior() + { + return false; + } + + // The guild master and the Jr. Masters (rank 2) invite (FUN_0053088f). + bool can_invite_to_guild(const Player& player) + { + const Guild& guild = player.get_guild(); + int32_t self = player.get_oid(); + const GuildMember* member = guild.find(self); + + return guild.exists() && (guild.is_master(self) || (member && member->rank == 2)); + } + + // /guildinvite's checks (FUN_005306d5). + void invite_to_guild(const std::string& name) + { + const Player& player = Stage::get().get_player(); + const Guild& guild = player.get_guild(); + + if (name == player.get_stats().get_name()) + notice("You cannot invite yourself to the guild."); + else if (!guild.exists()) + notice("You are not in the guild."); + else if (!can_invite_to_guild(player)) + notice("You are not the master of the guild."); + else if (guild.get_data().members.size() >= static_cast(guild.get_data().capacity)) + notice("You cannot invite more people to the guild."); + else + InviteToGuildPacket(name).dispatch(); + } + } + + UIContextMenu::UIContextMenu(int32_t c, const std::string& n, Point cursor) : + UIElement(Point(), Point()), cid(c), name(n) + { + nl::node menu = NxFiles::ui_classic["UIWindow.img"]["ContextMenu"]; + + top = menu["t"]; + middle = menu["c"]; + bottom = menu["s"]; + + // Only the party's leader, or someone in none, sees the party invite. + Player& player = Stage::get().get_player(); + const Party& party = player.get_party(); + bool party_invite = !party.exists() || party.is_leader(player.get_oid()); + + std::vector entries = + context_menu::entries(party_invite, can_invite_to_guild(player), can_add_junior()); + rows = entries.size(); + + for (size_t row = 0; row < rows; row++) + { + Point at(BUTTON_X, BUTTON_Y + static_cast(row) * context_menu::ROW); + buttons[entries[row]] = std::make_unique(menu[BUTTONS[entries[row]]], at); + } + + title = Text(Text::A12M, Text::LEFT, Color::Name::WHITE, name); + string_format::format_with_ellipsis(title, TITLE_WIDTH); + + auto& constants = Constants::Constants::get(); + position = context_menu::place(cursor, rows, constants.get_viewwidth(), constants.get_viewheight()); + dimension = Point(context_menu::WIDTH, context_menu::height(rows)); + } + + void UIContextMenu::draw(float alpha) const + { + top.draw(position); + + for (size_t row = 0; row < rows; row++) + middle.draw(position + Point(STRIP_X, context_menu::TOP + static_cast(row) * context_menu::ROW)); + + bottom.draw(position + Point(STRIP_X, context_menu::TOP + static_cast(rows) * context_menu::ROW)); + title.draw(position + TITLE_AT); + + UIElement::draw_buttons(alpha); + } + + // A right button coming up outside counts as a press there (FUN_00997379). + void UIContextMenu::rightclick(Point cursorpos) + { + if (!is_in_range(cursorpos)) + deactivate(); + } + + // A press outside closes the menu (FUN_0099735f). + Cursor::State UIContextMenu::send_cursor(bool clicked, Point cursorpos) + { + if (clicked && !is_in_range(cursorpos)) + { + deactivate(); + + return Cursor::State::IDLE; + } + + return UIElement::send_cursor(clicked, cursorpos); + } + + // ESC closes the menu, and so does Enter, picking nothing. + void UIContextMenu::send_key(int32_t keycode, bool pressed, bool escape) + { + if (pressed && (escape || keycode == KeyAction::Id::RETURN)) + deactivate(); + } + + UIElement::Type UIContextMenu::get_type() const + { + return TYPE; + } + + // The menu closes first, then acts (FUN_0095009a). + Button::State UIContextMenu::button_pressed(uint16_t buttonid) + { + deactivate(); + + Player& player = Stage::get().get_player(); + auto chatbar = UI::get().get_element(); + + switch (buttonid) + { + case context_menu::INFO: + // As a double click on them (FUN_00a23fba). + if (Stage::get().get_character(cid) && player.request(CharInfoRequestPacket(cid), ExclRequest::USE_WAIT)) + UI::get().emplace(cid); + break; + case context_menu::SAVE_NAME: + // Copied for pasting into the chat, which opens (FUN_009f967a). + Window::get().setclipboard(name); + + if (chatbar) + chatbar->toggle_chatfield(true); + break; + case context_menu::TRADE: + UITradingRoom::request(cid); + break; + case context_menu::WHISPER: + if (chatbar) + chatbar->start_whisper(name); + break; + case context_menu::PARTY: + PartyCommands::invite(name); + break; + case context_menu::GUILD: + invite_to_guild(name); + break; + case context_menu::CLAIM: + // The server never opens reports, so v83 says they're down + // (FUN_00a2719c). + UI::get().emplace("The reporting system is currently undergoing maintenence check.", [](bool) {}); + break; + default: + break; + } + + return Button::State::NORMAL; + } +} diff --git a/HeavenClient/IO/UITypes/UIContextMenu.h b/HeavenClient/IO/UITypes/UIContextMenu.h new file mode 100644 index 000000000..a88a6e75d --- /dev/null +++ b/HeavenClient/IO/UITypes/UIContextMenu.h @@ -0,0 +1,48 @@ +#pragma once + +#include "../UIElement.h" + +#include "../../Graphics/Text.h" +#include "../../Graphics/Texture.h" + +#include +#include + +namespace ms +{ + // v83's menu on someone else (CContextMenu, FUN_00996570), opened by + // releasing the right button over them: their info, saving their name, + // trade, whisper, party and guild invites and alerting a GM. It holds + // the mouse and keys until a row is picked, a press lands outside it or + // ESC closes it. + class UIContextMenu : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::CONTEXTMENU; + static constexpr bool FOCUSED = true; + static constexpr bool TOGGLED = false; + + // cursor: where the button came up, in the view. + UIContextMenu(int32_t cid, const std::string& name, Point cursor); + + void draw(float inter) const override; + void rightclick(Point cursorpos) override; + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + int32_t cid; + std::string name; + size_t rows; + + Texture top; + Texture middle; + Texture bottom; + Text title; + }; +} diff --git a/HeavenClient/IO/UITypes/UITradingRoom.cpp b/HeavenClient/IO/UITypes/UITradingRoom.cpp index 230cdbd05..9b25ece85 100644 --- a/HeavenClient/IO/UITypes/UITradingRoom.cpp +++ b/HeavenClient/IO/UITypes/UITradingRoom.cpp @@ -1,5 +1,6 @@ #include "UITradingRoom.h" +#include "UIChatBar.h" #include "UINotice.h" #include "../UI.h" @@ -380,6 +381,28 @@ namespace ms deactivate(); } + void UITradingRoom::request(int32_t cid) + { + auto room = UI::get().get_element(); + const char *refusal = nullptr; + + if (room && room->is_active()) + refusal = "Not available"; + else if (Stage::get().is_player(cid) || !Stage::get().get_character(cid)) + refusal = "Unable to find the character."; + + if (refusal) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(refusal, UIChatBar::LineType::SKILL); + + return; + } + + TradeCreatePacket().dispatch(); + MiniRoomInvitePacket(cid).dispatch(); + } + void UITradingRoom::ask_meso() { Player &player = Stage::get().get_player(); diff --git a/HeavenClient/IO/UITypes/UITradingRoom.h b/HeavenClient/IO/UITypes/UITradingRoom.h index 196213f81..a6492da9e 100644 --- a/HeavenClient/IO/UITypes/UITradingRoom.h +++ b/HeavenClient/IO/UITypes/UITradingRoom.h @@ -71,6 +71,11 @@ namespace ms // itself when we leave the field. void close(); + // Asks cid to trade, as the character info window and the menu on + // someone do (FUN_0052fad4): one room at a time, with someone else + // on the map. + static void request(int32_t cid); + protected: Button::State button_pressed(uint16_t buttonid) override; diff --git a/HeavenClient/IO/Window.cpp b/HeavenClient/IO/Window.cpp index 2ac9de6f8..de9412052 100644 --- a/HeavenClient/IO/Window.cpp +++ b/HeavenClient/IO/Window.cpp @@ -86,9 +86,11 @@ namespace ms break; case GLFW_MOUSE_BUTTON_RIGHT: + // v83 acts on the right button coming up, in the field, the + // buff list and the Monster Book (WM_RBUTTONUP). switch (action) { - case GLFW_PRESS: + case GLFW_RELEASE: UI::get().rightclick(); break; } diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 0b68976f5..9b3db1a44 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -378,7 +378,8 @@ namespace ms public: enum Operation : int8_t { - CREATE = 2 + CREATE = 2, + INVITE = 5 }; GuildOperationPacket(Operation op) : OutPacket(OutPacket::Opcode::GUILD_OPERATION) @@ -397,6 +398,16 @@ namespace ms } }; + // Invites a character to the player's guild + class InviteToGuildPacket : public GuildOperationPacket + { + public: + InviteToGuildPacket(const std::string& name) : GuildOperationPacket(GuildOperationPacket::Operation::INVITE) + { + write_string(name); + } + }; + // Opcode: BUDDYLIST_MODIFY(130) class BuddyListModifyPacket : public OutPacket { diff --git a/HeavenClient/tests/char_click.cpp b/HeavenClient/tests/char_click.cpp index 34a5634d6..27fc1825c 100644 --- a/HeavenClient/tests/char_click.cpp +++ b/HeavenClient/tests/char_click.cpp @@ -23,6 +23,20 @@ int main() require(!hits(feet, Point(400, 301)), "below the feet is a miss"); require(!hits(feet, Point(400, 229)), "above the head is a miss"); + using char_click::menu_hits; + + require(menu_hits(feet, false, false, Point(378, 235)), "the menu box's top-left corner is in"); + require(!menu_hits(feet, false, false, Point(422, 270)), "the menu box's right edge is out"); + require(!menu_hits(feet, false, false, Point(400, 300)), "the menu box's bottom edge is out"); + require(!menu_hits(feet, false, false, Point(400, 234)), "above the menu box is a miss"); + require(menu_hits(feet, false, true, Point(421, 299)), "facing right, standing, the box is the same"); + require(menu_hits(feet, true, false, Point(354, 269)), "prone facing left, the box reaches left"); + require(!menu_hits(feet, true, false, Point(400, 280)), "prone facing left, the box stops at the feet"); + require(!menu_hits(feet, true, false, Point(380, 268)), "prone, above the body is a miss"); + require(menu_hits(feet, true, true, Point(400, 280)), "prone facing right, the box starts at the feet"); + require(menu_hits(feet, true, true, Point(445, 280)), "prone facing right, the box reaches right"); + require(!menu_hits(feet, true, true, Point(399, 280)), "prone facing right, behind the feet is a miss"); + std::cout << "Character click passed\n"; return 0; } diff --git a/HeavenClient/tests/context_menu.cpp b/HeavenClient/tests/context_menu.cpp new file mode 100644 index 000000000..3617342da --- /dev/null +++ b/HeavenClient/tests/context_menu.cpp @@ -0,0 +1,42 @@ +#include "../IO/Components/ContextMenuLayout.h" +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms; + using namespace context_menu; + + std::vector base = { INFO, SAVE_NAME, TRADE, WHISPER, CLAIM }; + require(entries(false, false, false) == base, "five rows with no invites"); + + std::vector all = { INFO, SAVE_NAME, TRADE, WHISPER, PARTY, GUILD, CLAIM, FAMILY }; + require(entries(true, true, true) == all, "invites sit between whisper and alert GM"); + + std::vector guild_only = { INFO, SAVE_NAME, TRADE, WHISPER, GUILD, CLAIM }; + require(entries(false, true, false) == guild_only, "a missing row lets the rest move up"); + + require(height(5) == 105, "five rows and the caps are 105 tall"); + + require(place(Point(300, 200), 5, 800, 600) == Point(300, 200), "the menu opens at the cursor"); + require(place(Point(700, 495), 5, 800, 600) == Point(700, 495), "a menu that just fits stays"); + require(place(Point(750, 200), 5, 800, 600) == Point(700, 200), "past the right it moves left"); + require(place(Point(300, 550), 5, 800, 600) == Point(300, 495), "past the bottom it moves up"); + require(place(Point(1000, 700), 6, 1024, 768) == Point(924, 648), "a larger view keeps it inside"); + + std::cout << "Context menu passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/client.md b/agent_docs/client.md index b17d13262..3fb98e9c9 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -246,6 +246,25 @@ from `UI.nx`. Moved so far: second try within 5 minutes ("Please try it again later.", `Gameplay/FameCooldown.h`). FAME_RESPONSE (`FameResponseHandler`) prints its v83 lines in the skill chat colour and updates the open window's fame. +- **Menu on someone** (`UIContextMenu`, `UIWindow.img/ContextMenu`): opened + by releasing the right button over another character (`Stage::other_character_at`, + v83's 44 by 65 box above the feet, or 46 by 31 toward the head when prone, + `char_click::menu_hits`). It opens at the cursor, pushed back inside the + view, with their name in the top cap. The rows are PERSONAL INFO, SAVE NAME, + TRADE REQUEST, WHISPER, PARTY INVITE (party leaders, or players in no + party), GUILD INVITE (guild masters and Jr. Masters), and ALERT GM; rows left + out move up (`IO/Components/ContextMenuLayout.h`). It holds the mouse and + keys: a press or right release outside, ESC, or Enter closes it, and it + closes before a row acts. Info opens the character info window, SAVE NAME + copies the name and opens the chat field, WHISPER fills in "/w name ", + TRADE and PARTY INVITE do what the info window's trade and `/party invite` + do, GUILD INVITE sends GUILD_OPERATION 5 after v83's guild checks (the + server ignores it until it has guild invites), and ALERT GM shows v83's + notice that reports are down. Approximations: v83 also hits + the character's second position and a morph's own box; it puts the chat + into whisper mode rather than filling in a command; and it offers Add + Junior to family members, which HC doesn't have. The right button acts when + it comes up, everywhere, as in v83. - **Quest log** (`UIQuestLog`, `UIWindow.img/Quest`): Available, In Progress, and Complete tabs (`Basic.img/Tab2`), quests folded by area, and a detail panel with the quest's text and FIND NPC or QUEST HELPER, laid out from From 92a373110b93719f41d15dd53fca672c0295d959 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 02:50:12 -0400 Subject: [PATCH 18/23] L182: v83's buddy flags, arrival cards and request checks The client now keeps each buddy's GW_Friend flag. Requests (flag 1) stay in the list but are hidden from the Friend tab, its counts and buddy chat; flag 2 is always offline. A buddy coming online shows v83's pink " is logged in." card (not for guildmates), and one moving to the player's channel shows "You're on the same ch. with .", each for six seconds with only an X. Adding, accepting and removing make v83's local checks first, and unanswered requests are asked again after each map load unless a request card is still up. Modes 8, 10, 0x12 and the server-text results are read. The server now sends unanswered requests in the list with flag 1 instead of asking about them one at a time, so declining one still finds its entry after the list is refreshed. --- HeavenClient/CMakeLists.txt | 2 + HeavenClient/Character/BuddyList.cpp | 93 +++++++++++++++++-- HeavenClient/Character/BuddyList.h | 27 +++++- HeavenClient/Character/Guild.cpp | 12 +++ HeavenClient/Character/Guild.h | 2 + HeavenClient/IO/BuddyCommands.cpp | 90 ++++++++++++++++++ HeavenClient/IO/BuddyCommands.h | 24 +++++ HeavenClient/IO/UITypes/UIInviteCard.cpp | 83 +++++++++++++---- HeavenClient/IO/UITypes/UIInviteCard.h | 29 +++--- HeavenClient/IO/UITypes/UIUserList.cpp | 13 ++- HeavenClient/Net/Handlers/BuddyHandlers.cpp | 76 ++++++++++++--- .../Net/Handlers/SetFieldHandlers.cpp | 4 + HeavenClient/tests/buddy_list.cpp | 36 ++++++- agent_docs/buddies.md | 35 ++++--- agent_docs/client.md | 5 +- integration-harness/src/trace.rs | 26 ++++-- integration-harness/tests/buddy_list.rs | 11 +++ net/src/packet/build/world/buddy.rs | 20 ++-- runtime/src/actor/client/buddies.rs | 33 +++---- runtime/src/actor/directory.rs | 2 + runtime/src/message.rs | 2 + 21 files changed, 511 insertions(+), 114 deletions(-) create mode 100644 HeavenClient/IO/BuddyCommands.cpp create mode 100644 HeavenClient/IO/BuddyCommands.h diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 9aa9c5fdc..645631dd1 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -617,6 +617,8 @@ add_executable(HeavenClient IO/KeyType.h IO/Messages.cpp IO/Messages.h + IO/BuddyCommands.cpp + IO/BuddyCommands.h IO/PartyCommands.cpp IO/PartyCommands.h IO/UI.cpp diff --git a/HeavenClient/Character/BuddyList.cpp b/HeavenClient/Character/BuddyList.cpp index 925d956d3..5fd67f90c 100644 --- a/HeavenClient/Character/BuddyList.cpp +++ b/HeavenClient/Character/BuddyList.cpp @@ -1,17 +1,45 @@ #include "BuddyList.h" +#include +#include #include namespace ms { + namespace + { + bool same_name(const std::string& a, const std::string& b) + { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), + [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); + } + + // The flags whose online state follows the channel. + bool by_channel(uint8_t flag) + { + return flag == 0 || flag == 3 || flag == 4; + } + } + + bool Buddy::pending() const + { + return flag == 1; + } + bool Buddy::online() const { - return channel >= 0; + return flag != 2 && !(by_channel(flag) && channel < 0); + } + + bool Buddy::hears_chat() const + { + return id != 0 && by_channel(flag) && channel >= 0; } bool Buddy::operator==(const Buddy& other) const { - return id == other.id && name == other.name && group == other.group && channel == other.channel; + return id == other.id && name == other.name && group == other.group && channel == other.channel + && flag == other.flag && in_cash_shop == other.in_cash_shop; } const std::vector& BuddyList::get_buddies() const @@ -28,9 +56,23 @@ namespace ms return nullptr; } + const Buddy* BuddyList::find_listed(const std::string& name) const + { + for (auto& buddy : buddies) + if (!buddy.pending() && same_name(buddy.name, name)) + return &buddy; + + return nullptr; + } + + size_t BuddyList::listed() const + { + return std::count_if(buddies.begin(), buddies.end(), [](const Buddy& buddy) { return !buddy.pending(); }); + } + bool BuddyList::is_full() const { - return buddies.size() >= capacity; + return listed() >= capacity; } uint8_t BuddyList::get_capacity() const @@ -43,7 +85,7 @@ namespace ms std::vector result; for (auto& buddy : buddies) - if (buddy.online()) + if (buddy.hears_chat()) result.push_back(buddy.id); return result; @@ -54,11 +96,48 @@ namespace ms buddies = std::move(new_buddies); } - void BuddyList::set_channel(int32_t cid, int32_t channel) + void BuddyList::add(Buddy buddy) + { + buddies.push_back(std::move(buddy)); + } + + void BuddyList::replace(int32_t cid, Buddy buddy) + { + for (auto& old : buddies) + if (old.id == cid) + { + old = std::move(buddy); + return; + } + } + + // Coming online counts first; otherwise only a move onto the player's + // channel is told. + BuddyList::Arrival BuddyList::set_channel(int32_t cid, int32_t channel, bool in_cash_shop, int32_t own_channel) { for (auto& buddy : buddies) - if (buddy.id == cid) - buddy.channel = channel; + { + if (buddy.id != cid) + continue; + + int32_t old = buddy.channel; + + if (old == channel && buddy.in_cash_shop == in_cash_shop) + return Arrival::NONE; + + buddy.channel = channel; + buddy.in_cash_shop = in_cash_shop; + + if (old < 0 && channel >= 0) + return Arrival::LOGGED_IN; + + if (old != channel && channel == own_channel) + return Arrival::SAME_CHANNEL; + + return Arrival::NONE; + } + + return Arrival::NONE; } void BuddyList::set_capacity(uint8_t new_capacity) diff --git a/HeavenClient/Character/BuddyList.h b/HeavenClient/Character/BuddyList.h index 4f444ee4c..e6b33d938 100644 --- a/HeavenClient/Character/BuddyList.h +++ b/HeavenClient/Character/BuddyList.h @@ -14,8 +14,18 @@ namespace ms // Zero-based channel, or negative while offline or until they add // the player back. int32_t channel = -1; + // v83's GW_Friend flag: 1 while they ask to be added and the player + // hasn't answered, 2 for someone always shown offline. + uint8_t flag = 0; + bool in_cash_shop = false; + // A request, which v83 keeps in the list but never shows or counts. + bool pending() const; + // v83's online test (FUN_00908fc4): flags 0, 3 and 4 go by the + // channel, 2 is always offline, and the rest always online. bool online() const; + // Whether buddy chat goes to them (FUN_00a3fb25). + bool hears_chat() const; bool operator==(const Buddy& other) const; }; @@ -25,9 +35,17 @@ namespace ms public: static constexpr uint8_t DEFAULT_CAPACITY = 20; - // Buddies in the server's order (by name). + // What a channel update tells the player (FUN_00a3f2e8, mode 0x14). + enum class Arrival { NONE, LOGGED_IN, SAME_CHANNEL }; + + // Buddies in the server's order (by name), requests included. const std::vector& get_buddies() const; const Buddy* find(int32_t cid) const; + // A buddy who isn't a request, by name regardless of case + // (FUN_00a3fa90). + const Buddy* find_listed(const std::string& name) const; + // How many buddies there are, not counting requests. + size_t listed() const; bool is_full() const; uint8_t get_capacity() const; @@ -35,7 +53,12 @@ namespace ms std::vector online_ids() const; void update(std::vector buddies); - void set_channel(int32_t cid, int32_t channel); + // A request's entry, which v83 adds as it comes (FUN_00a40028). + void add(Buddy buddy); + // Overwrites the entry for cid, if there is one (FUN_00a400ad). + void replace(int32_t cid, Buddy buddy); + // own_channel: the player's, zero-based. + Arrival set_channel(int32_t cid, int32_t channel, bool in_cash_shop, int32_t own_channel); void set_capacity(uint8_t capacity); private: diff --git a/HeavenClient/Character/Guild.cpp b/HeavenClient/Character/Guild.cpp index 05de77834..c895641bf 100644 --- a/HeavenClient/Character/Guild.cpp +++ b/HeavenClient/Character/Guild.cpp @@ -79,6 +79,18 @@ namespace ms return nullptr; } + const GuildMember* Guild::find_by_name(const std::string& name) const + { + if (!exists()) + return nullptr; + + for (auto& member : data.members) + if (member.name == name) + return &member; + + return nullptr; + } + bool Guild::is_master(int32_t cid) const { return exists() && !data.members.empty() && data.members.front().id == cid; diff --git a/HeavenClient/Character/Guild.h b/HeavenClient/Character/Guild.h index a30503e85..cffb03c33 100644 --- a/HeavenClient/Character/Guild.h +++ b/HeavenClient/Character/Guild.h @@ -77,6 +77,8 @@ namespace ms bool exists() const; const GuildData& get_data() const; const GuildMember* find(int32_t cid) const; + // A member by name, matching case (FUN_00a39282). + const GuildMember* find_by_name(const std::string& name) const; // The client takes the first member for the master. bool is_master(int32_t cid) const; // A rank's title, or "" for none. diff --git a/HeavenClient/IO/BuddyCommands.cpp b/HeavenClient/IO/BuddyCommands.cpp new file mode 100644 index 000000000..d85845768 --- /dev/null +++ b/HeavenClient/IO/BuddyCommands.cpp @@ -0,0 +1,90 @@ +#include "BuddyCommands.h" + +#include "UI.h" + +#include "UITypes/UIInviteCard.h" +#include "UITypes/UINotice.h" + +#include "../Gameplay/Stage.h" +#include "../Net/Packets/GameplayPackets.h" +#include "../Util/CharacterName.h" + +#include +#include + +namespace ms +{ + namespace BuddyCommands + { + namespace + { + void notice(const std::string& message) + { + UI::get().emplace(message, [](bool) {}); + } + + bool same_name(const std::string& a, const std::string& b) + { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), + [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); + } + } + + void add(const std::string& name, const std::string& group) + { + const Player& player = Stage::get().get_player(); + const Buddy* listed = player.get_buddies().find_listed(name); + + if (!character_name_valid(name)) + notice("You have entered an invalid character name."); + else if (listed && listed->group == group) + notice("The buddy is already registered."); + else if (same_name(name, player.get_stats().get_name())) + notice("You can't enter yourself as your buddy."); + else + AddBuddyPacket(name, group).dispatch(); + } + + void accept(int32_t cid) + { + const Buddy* buddy = Stage::get().get_player().get_buddies().find(cid); + + if (!buddy || buddy->pending()) + AcceptBuddyPacket(cid).dispatch(); + else + notice("You have not requested buddies with that user."); + } + + void remove(int32_t cid) + { + if (Stage::get().get_player().get_buddies().find(cid)) + DeleteBuddyPacket(cid).dispatch(); + else + notice("The user is not registered."); + } + + void ask(const std::string& name, int32_t cid) + { + if (auto cards = UI::get().get_element()) + cards->add(UIInviteCard::BUDDY, name, [cid](bool yes) + { + if (yes) + accept(cid); + else + remove(cid); + }); + } + + void show_requests() + { + auto cards = UI::get().get_element(); + + if (!cards || cards->showing(UIInviteCard::BUDDY)) + return; + + for (const Buddy& buddy : Stage::get().get_player().get_buddies().get_buddies()) + if (buddy.pending()) + ask(buddy.name, buddy.id); + } + } +} diff --git a/HeavenClient/IO/BuddyCommands.h b/HeavenClient/IO/BuddyCommands.h new file mode 100644 index 000000000..55217a467 --- /dev/null +++ b/HeavenClient/IO/BuddyCommands.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace ms +{ + // v83's buddy list requests, with the checks the client makes before + // asking the server. Each refusal is an OK notice. + namespace BuddyCommands + { + // FUN_00531021 + void add(const std::string& name, const std::string& group); + // Only someone who asked, or isn't listed (FUN_00531270). + void accept(int32_t cid); + // Removes a buddy or turns down a request (FUN_005311c1). + void remove(int32_t cid); + // Shows cid's request to be added (FUN_00522d22). + void ask(const std::string& name, int32_t cid); + // Asks again about each request still unanswered, unless one is + // showing, as v83 does once a map has loaded (FUN_00a3fecf). + void show_requests(); + } +} diff --git a/HeavenClient/IO/UITypes/UIInviteCard.cpp b/HeavenClient/IO/UITypes/UIInviteCard.cpp index b2c10b838..1d27dd869 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.cpp +++ b/HeavenClient/IO/UITypes/UIInviteCard.cpp @@ -17,12 +17,14 @@ namespace ms // Placed like the quest-complete notice (FUN_00523408). constexpr int16_t LEFT = 464, ABOVE_BOTTOM = 97; const Point OK_AT(174, 5), CANCEL_AT(174, 19); + // The arrivals' only button, at v83's x 136 like the others are at 177. + const Point ARRIVAL_CANCEL_AT(133, 19); // The icon's left edge and the band it is centred in (FUN_0052065d). constexpr int16_t ICON_X = 6, ICON_BAND = 37; // How far the "from" line sits below the heading. constexpr int16_t LINE = 13; // How long from appearing until it fades out unanswered. - constexpr uint32_t LIFETIME = 180000; + constexpr uint32_t LIFETIME = 180000, ARRIVAL_LIFETIME = 6000; struct Look { @@ -32,12 +34,16 @@ namespace ms Point text; }; - const Look LOOKS[UIInviteCard::NUM_KINDS] = { + // The invitations'. + const Look LOOKS[UIInviteCard::ARRIVAL] = { { "icon0", "An invitation", Point(24, 2) }, { "icon1", "Buddy request", Point(24, 2) }, { "icon2", "Trade request", Point(24, 2) }, { "icon5", "Party invite", Point(26, 4) } }; + + // v83 writes the arrivals at (25, 7), in Arial 11. + const Point ARRIVAL_TEXT(24, 4); } UIInviteCard::UIInviteCard() : UIElement(Point(), Point(WIDTH, 0)) @@ -45,12 +51,7 @@ namespace ms nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; background = fade["backgrnd"]; - - for (size_t kind = 0; kind < NUM_KINDS; kind++) - { - icons[kind] = fade[LOOKS[kind].icon]; - headings[kind] = Text(Text::A12M, Text::LEFT, Color::Name::WHITE, LOOKS[kind].heading); - } + arrival_background = fade["backgrnd2"]; update_screen(Constants::Constants::get().get_viewwidth(), Constants::Constants::get().get_viewheight()); } @@ -61,13 +62,12 @@ namespace ms { Point at = position + offset(notice); float opacity = notice.life.opacity(); - const Texture& icon = icons[notice.kind]; - Point text = LOOKS[notice.kind].text; + const Texture& icon = notice.icon; - background.draw(DrawArgument(at, opacity)); + (notice.kind == ARRIVAL ? arrival_background : background).draw(DrawArgument(at, opacity)); icon.draw(DrawArgument(at + Point(ICON_X, (ICON_BAND - icon.get_dimensions().y()) / 2), opacity)); - headings[notice.kind].draw(DrawArgument(at + text, opacity)); - notice.from.draw(DrawArgument(at + text + Point(0, LINE), opacity)); + notice.first.draw(DrawArgument(at + notice.text, opacity)); + notice.second.draw(DrawArgument(at + notice.text + Point(0, LINE), opacity)); } // Only the top notice can be reached; the others' buttons lie under it. @@ -110,13 +110,46 @@ namespace ms void UIInviteCard::add(Kind kind, const std::string& inviter, std::function answer) { + const Look& look = LOOKS[kind]; + nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; + + push({ + kind, + fade[look.icon], + Text(Text::A12M, Text::LEFT, Color::Name::WHITE, look.heading), + Text(Text::A12M, Text::LEFT, Color::Name::WHITE, "from '" + inviter + "'"), + look.text, + std::move(answer), + FadeNotice(LIFETIME) + }); + } + + void UIInviteCard::notify(const std::string& name, bool same_channel) + { + nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; + std::string first = same_channel ? "You're on the same ch." : name + " is"; + std::string second = same_channel ? "with " + name + "." : "logged in."; + + push({ + ARRIVAL, + fade[same_channel ? "icon4" : "icon3"], + Text(Text::A11M, Text::LEFT, Color::Name::WHITE, first), + Text(Text::A11M, Text::LEFT, Color::Name::WHITE, second), + ARRIVAL_TEXT, + nullptr, + FadeNotice(ARRIVAL_LIFETIME) + }); + } + + void UIInviteCard::push(Notice notice) + { + Kind kind = notice.kind; + if (kind != BUDDY) notices.erase(std::remove_if(notices.begin(), notices.end(), [kind](const Notice& n) { return n.kind == kind; }), notices.end()); - Text from(Text::A12M, Text::LEFT, Color::Name::WHITE, "from '" + inviter + "'"); - - notices.push_back({ kind, std::move(from), std::move(answer), FadeNotice(LIFETIME) }); + notices.push_back(std::move(notice)); Sound(Sound::Name::INVITE).play(); rebuild(); @@ -139,8 +172,15 @@ namespace ms buttons_raised = notices.back().life.raised(); Point at = offset(notices.back()); - buttons[OK] = std::make_unique(fade["BtOK"], at + OK_AT); - buttons[CANCEL] = std::make_unique(fade["BtCancel"], at + CANCEL_AT); + if (notices.back().kind == ARRIVAL) + { + buttons[CANCEL] = std::make_unique(fade["BtCancel"], at + ARRIVAL_CANCEL_AT); + } + else + { + buttons[OK] = std::make_unique(fade["BtOK"], at + OK_AT); + buttons[CANCEL] = std::make_unique(fade["BtCancel"], at + CANCEL_AT); + } } Point UIInviteCard::offset(const Notice& notice) const @@ -158,8 +198,9 @@ namespace ms if (notices.empty()) return false; + bool invitations = std::any_of(notices.begin(), notices.end(), [](const Notice& n) { return n.kind != ARRIVAL; }); Point top = position + offset(notices.back()); - Point bottom = position + offset(notices.front()) + Point(WIDTH, HEIGHT); + Point bottom = position + offset(notices.front()) + Point(invitations ? WIDTH : ARRIVAL_WIDTH, HEIGHT); return Rectangle(top, bottom).contains(cursorpos); } @@ -176,7 +217,9 @@ namespace ms // The answer may add a notice; let it work on its own copy. std::function answer = notice.answer; - answer(buttonid == OK); + + if (answer) + answer(buttonid == OK); return Button::State::NORMAL; } diff --git a/HeavenClient/IO/UITypes/UIInviteCard.h b/HeavenClient/IO/UITypes/UIInviteCard.h index e6072ce22..d71088c63 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.h +++ b/HeavenClient/IO/UITypes/UIInviteCard.h @@ -6,7 +6,6 @@ #include "../../Graphics/Text.h" #include "../../Graphics/Texture.h" -#include #include #include #include @@ -18,9 +17,11 @@ namespace ms // FUN_0052065d): an invitation to Maple Messenger, a buddy request, a // trade request or a party invite, each "from ''". O accepts and X // turns it down; one left alone fades out after three minutes and sends - // nothing. A new one replaces any of its kind still there, except buddy - // requests, which pile up (FUN_00a247cf). Newer notices stack above older - // ones, quest completions included. + // nothing. Smaller pink ones say a buddy logged in or came to the + // player's channel, for six seconds, with only an X. A new one replaces + // any of its kind still there, except buddy requests, which pile up + // (FUN_00a247cf). Newer notices stack above older ones, quest + // completions included. class UIInviteCard : public UIElement { public: @@ -28,8 +29,8 @@ namespace ms static constexpr bool FOCUSED = false; static constexpr bool TOGGLED = false; - // v83's notice types 0, 1, 2 and 5. - enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, NUM_KINDS }; + // v83's notice types 0, 1, 2, 5 and 9. + enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, ARRIVAL, NUM_KINDS }; UIInviteCard(); @@ -42,6 +43,8 @@ namespace ms // `answer` hears true for O and false for X. void add(Kind kind, const std::string& inviter, std::function answer); + // A buddy logged in, or came to the player's channel (FUN_00523a2c). + void notify(const std::string& name, bool same_channel); // Whether a notice of `kind` is still there, even fading out. bool showing(Kind kind) const; @@ -54,18 +57,22 @@ namespace ms struct Notice { Kind kind; - Text from; + Texture icon; + Text first, second; + // Where the first line sits. + Point text; + // Empty for the arrivals, which ask nothing. std::function answer; FadeNotice life; }; + void push(Notice notice); + void rebuild(); // From the bottom of the stack to `notice`. Point offset(const Notice& notice) const; - Texture background; - std::array icons; - std::array headings; + Texture background, arrival_background; // Oldest first; the newest is drawn last, over the others. std::vector notices; // How high the buttons sit, to move them when the stack shifts. @@ -73,6 +80,6 @@ namespace ms // The window v83 makes for each (FUN_0051f9ad), on the same layer as // dialogs, so a dialog opened later covers it. - static constexpr int16_t WIDTH = 196, HEIGHT = 44; + static constexpr int16_t WIDTH = 196, ARRIVAL_WIDTH = 155, HEIGHT = 44; }; } diff --git a/HeavenClient/IO/UITypes/UIUserList.cpp b/HeavenClient/IO/UITypes/UIUserList.cpp index 8e87ef98b..d54143f59 100644 --- a/HeavenClient/IO/UITypes/UIUserList.cpp +++ b/HeavenClient/IO/UITypes/UIUserList.cpp @@ -20,6 +20,7 @@ #include "UIChatBar.h" #include "UIEnterText.h" +#include "../BuddyCommands.h" #include "../PartyCommands.h" #include "../UI.h" @@ -760,7 +761,7 @@ namespace ms "Please enter the name of the character\\nyou'll add as a buddy.", [](const std::string& name) { - AddBuddyPacket(name, DEFAULT_GROUP).dispatch(); + BuddyCommands::add(name, DEFAULT_GROUP); }, NAME_LIMIT ); @@ -775,7 +776,7 @@ namespace ms [id](bool yes) { if (yes) - DeleteBuddyPacket(id).dispatch(); + BuddyCommands::remove(id); } ); } @@ -951,6 +952,10 @@ namespace ms for (const Buddy& buddy : shown_buddies) { + // Requests wait on their cards instead. + if (buddy.pending()) + continue; + if (!buddy.online()) away.push_back(&buddy); else if (buddy.channel == channel) @@ -959,7 +964,7 @@ namespace ms other.push_back(&buddy); } - std::string total = "/" + std::to_string(shown_buddies.size()); + std::string total = "/" + std::to_string(buddies.listed()); buddies_online.count.change_text(std::to_string(same.size() + other.size()) + total); buddies_offline.count.change_text(std::to_string(away.size()) + total); @@ -1042,7 +1047,7 @@ namespace ms enable(Buttons::BT_ADD_BUDDY, !buddies.is_full()); enable(Buttons::BT_DELETE_BUDDY, buddy); enable(Buttons::BT_WHISPER_BUDDY, buddy && buddy->online()); - enable(Buttons::BT_BUDDY_CHAT, !buddies.get_buddies().empty()); + enable(Buttons::BT_BUDDY_CHAT, buddies.listed() > 0); enable(Buttons::BT_BUDDY_MAP, false); // Inviting someone makes a party first if the player has none. enable(Buttons::BT_INVITE_BUDDY, buddy && buddy->online() && party_has_room); diff --git a/HeavenClient/Net/Handlers/BuddyHandlers.cpp b/HeavenClient/Net/Handlers/BuddyHandlers.cpp index efddc8e14..70fd671ee 100644 --- a/HeavenClient/Net/Handlers/BuddyHandlers.cpp +++ b/HeavenClient/Net/Handlers/BuddyHandlers.cpp @@ -2,7 +2,9 @@ #include "../Packets/GameplayPackets.h" +#include "../../Configuration.h" #include "../../Gameplay/Stage.h" +#include "../../IO/BuddyCommands.h" #include "../../IO/UI.h" #include "../../IO/UITypes/UIInviteCard.h" #include "../../IO/UITypes/UINotice.h" @@ -14,14 +16,21 @@ namespace ms enum Operation : int8_t { LIST = 0x07, + UPDATE = 0x08, REQUEST = 0x09, + LIST_AGAIN = 0x0A, OWN_LIST_FULL = 0x0B, THEIR_LIST_FULL = 0x0C, ALREADY_ADDED = 0x0D, NO_GAMEMASTERS = 0x0E, NOT_FOUND = 0x0F, + RESULT = 0x10, + RESULT_2 = 0x11, + LIST_ONCE_MORE = 0x12, + RESULT_3 = 0x13, CHANNEL = 0x14, - CAPACITY = 0x15 + CAPACITY = 0x15, + RESULT_4 = 0x16 }; constexpr uint16_t NAME_LENGTH = 13; @@ -33,7 +42,7 @@ namespace ms Buddy buddy; buddy.id = recv.read_int(); buddy.name = recv.read_padded_string(NAME_LENGTH); - recv.read_byte(); // Whether they are asking to be added + buddy.flag = static_cast(recv.read_byte()); buddy.channel = recv.read_int(); buddy.group = recv.read_padded_string(GROUP_LENGTH); @@ -53,7 +62,10 @@ namespace ms switch (op) { + // v83 reads all three the same way (FUN_00a3ff8b). case Operation::LIST: + case Operation::LIST_AGAIN: + case Operation::LIST_ONCE_MORE: { uint8_t count = static_cast(recv.read_byte()); std::vector list; @@ -61,25 +73,32 @@ namespace ms for (uint8_t i = 0; i < count; i++) list.push_back(read_buddy(recv)); - // Whether each is in the Cash Shop - recv.skip(count * 4); + for (Buddy& buddy : list) + buddy.in_cash_shop = recv.read_int() != 0; buddies.update(std::move(list)); break; } + // Changes a listed buddy and adds nobody (FUN_00a400ad). + case Operation::UPDATE: + { + int32_t id = recv.read_int(); + Buddy buddy = read_buddy(recv); + buddy.in_cash_shop = recv.read_bool(); + buddies.replace(id, std::move(buddy)); + break; + } + // The request joins the list, hidden, so that accepting it works + // and it can be asked again after a map change (FUN_00a40028). case Operation::REQUEST: { int32_t id = recv.read_int(); std::string name = recv.read_string(); + Buddy buddy = read_buddy(recv); + buddy.in_cash_shop = recv.read_bool(); + buddies.add(std::move(buddy)); - if (auto cards = UI::get().get_element()) - cards->add(UIInviteCard::BUDDY, name, [id](bool yes) - { - if (yes) - AcceptBuddyPacket(id).dispatch(); - else - DeleteBuddyPacket(id).dispatch(); - }); + BuddyCommands::ask(name, id); break; } case Operation::OWN_LIST_FULL: @@ -97,13 +116,42 @@ namespace ms case Operation::NOT_FOUND: notice("That character is not registered."); break; + // A buddy logging in or moving to the player's channel shows a card, + // unless they are in the player's guild, which tells them itself. case Operation::CHANNEL: { int32_t id = recv.read_int(); - recv.read_byte(); // In the Cash Shop - buddies.set_channel(id, recv.read_int()); + bool in_cash_shop = recv.read_bool(); + int32_t channel = recv.read_int(); + BuddyList::Arrival arrival = buddies.set_channel(id, channel, in_cash_shop, Configuration::get().get_channelid()); + + if (arrival == BuddyList::Arrival::NONE) + break; + + const Buddy* buddy = buddies.find(id); + auto cards = UI::get().get_element(); + + if (!buddy || !cards) + break; + + if (arrival == BuddyList::Arrival::SAME_CHANNEL) + cards->notify(buddy->name, true); + else if (!Stage::get().get_player().get_guild().find_by_name(buddy->name)) + cards->notify(buddy->name, false); + break; } + // The server's own words, if any (FUN_00428211). + case Operation::RESULT: + case Operation::RESULT_2: + case Operation::RESULT_3: + case Operation::RESULT_4: + if (recv.read_bool()) + notice(recv.read_string()); + else + notice("The request was denied due to an unknown error."); + + break; case Operation::CAPACITY: buddies.set_capacity(static_cast(recv.read_byte())); break; diff --git a/HeavenClient/Net/Handlers/SetFieldHandlers.cpp b/HeavenClient/Net/Handlers/SetFieldHandlers.cpp index cedc8a39a..85b1c1510 100644 --- a/HeavenClient/Net/Handlers/SetFieldHandlers.cpp +++ b/HeavenClient/Net/Handlers/SetFieldHandlers.cpp @@ -27,6 +27,7 @@ #include "../../Configuration.h" #include "../../Gameplay/Stage.h" +#include "../../IO/BuddyCommands.h" #include "../../IO/UI.h" #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UINotice.h" @@ -59,6 +60,9 @@ namespace ms GraphicsGL::get().unlock(); Stage::get().transfer_player(); + + // Requests left unanswered come back (FUN_00a3fecf). + BuddyCommands::show_requests(); }); GraphicsGL::get().lock(); diff --git a/HeavenClient/tests/buddy_list.cpp b/HeavenClient/tests/buddy_list.cpp index 3a151322a..5c185ad36 100644 --- a/HeavenClient/tests/buddy_list.cpp +++ b/HeavenClient/tests/buddy_list.cpp @@ -13,16 +13,20 @@ int main() { using namespace ms; - auto buddy = [](int32_t id, const char* name, int32_t channel) + auto buddy = [](int32_t id, const char* name, int32_t channel, uint8_t flag = 0) { Buddy b; b.id = id; b.name = name; b.group = "Default Group"; b.channel = channel; + b.flag = flag; return b; }; + using Arrival = BuddyList::Arrival; + constexpr int32_t OWN = 1; + BuddyList list; require(list.get_buddies().empty() && !list.is_full(), "a new player has no buddies"); require(list.get_capacity() == BuddyList::DEFAULT_CAPACITY, "the list starts at the default size"); @@ -32,11 +36,33 @@ int main() require(!list.find(3)->online(), "channel -1 is offline"); require(list.online_ids() == std::vector{ 2 }, "only online buddies hear buddy chat"); - list.set_channel(3, 2); - list.set_channel(2, -1); + require(list.set_channel(3, OWN, false, OWN) == Arrival::LOGGED_IN, "coming online is told first"); + require(list.set_channel(2, -1, false, OWN) == Arrival::NONE, "logging out tells nothing"); require(list.online_ids() == std::vector{ 3 }, "channel updates move buddies on and off"); - list.set_channel(9, 0); - require(!list.find(9), "an update for a stranger adds nobody"); + require(list.set_channel(3, OWN, false, OWN) == Arrival::NONE, "an unchanged channel tells nothing"); + require(list.set_channel(3, 0, false, OWN) == Arrival::NONE, "leaving the player's channel tells nothing"); + require(list.set_channel(3, OWN, false, OWN) == Arrival::SAME_CHANNEL, "coming to the player's channel is told"); + require(list.set_channel(3, OWN, true, OWN) == Arrival::NONE && list.find(3)->in_cash_shop, + "the Cash Shop byte alone is stored but tells nothing"); + require(list.set_channel(9, 0, false, OWN) == Arrival::NONE && !list.find(9), "an update for a stranger adds nobody"); + + // Requests stay in the list, unseen and uncounted. + list.update({ buddy(2, "Bobby", 0), buddy(4, "Dave", 0, 1) }); + list.add(buddy(5, "Erin", 2, 1)); + require(list.get_buddies().size() == 3 && list.listed() == 1, "requests aren't counted"); + require(list.find(5) && list.find(5)->pending(), "a request is kept"); + require(list.online_ids() == std::vector{ 2 }, "requests don't hear buddy chat"); + require(!list.find_listed("dave") && list.find_listed("bOBBY") == list.find(2), + "names are found regardless of case, requests left out"); + + list.replace(4, buddy(4, "Dave", 0)); + require(!list.find(4)->pending() && list.listed() == 2, "an update overwrites the entry"); + list.replace(6, buddy(6, "Fred", 0)); + require(!list.find(6), "an update for a stranger adds nobody"); + + Buddy hidden = buddy(7, "Gail", 0, 2); + require(!hidden.online() && !hidden.hears_chat(), "flag 2 is always offline"); + require(buddy(7, "Gail", -1, 5).online(), "flags past 4 are always online"); list.set_capacity(2); require(list.is_full(), "the list fills at its capacity"); diff --git a/agent_docs/buddies.md b/agent_docs/buddies.md index 7e97b13ab..5a5090323 100644 --- a/agent_docs/buddies.md +++ b/agent_docs/buddies.md @@ -29,11 +29,13 @@ characters list each other without `pending`. are both mutual and online; everyone else gets -1. - `DirectoryMessage::Deliver` sends a packet to whichever of a set of characters is online. Requests, channel updates, and buddy chat use it. +- Every list carries the unanswered requests too, flagged (`GW_Friend` flag + 1), as v83 expects: the client hides them, and asks about them itself + once a map has loaded. A request made while the player is online is also + sent on its own (mode 9). - On entering the game, the client actor: - sends the list (or nothing, if it is empty); - - tells mutual buddies its channel; - - asks about the first unanswered request. -- Answering a request asks about the next one. + - tells mutual buddies its channel. - On logout, mutual buddies see channel -1. A channel change skips that, since the new channel announces itself. - Buddy chat only reaches the listed recipients who are mutual buddies. The @@ -44,15 +46,26 @@ entry is the 39-byte `GW_Friend` record. Its channel is zero-based, or -1. ## Client -- `Character/BuddyList` holds the list as the server last described it. The - player owns it. `SET_FIELD` sets its capacity. +- `Character/BuddyList` holds the list as the server last described it, + requests included. The player owns it. `SET_FIELD` sets its capacity. + Each entry keeps v83's flag: 1 is a request, hidden and not counted; 2 is + always offline; 0, 3 and 4 go by the channel; higher ones are always + online. Buddy chat goes to flags 0, 3 and 4 on a channel. - `Net/Handlers/BuddyHandlers` applies `BUDDYLIST` (63): - - the list; - - channel changes; + - the list (modes 7, 10 and 0x12), or one entry (8); + - channel changes (0x14). A buddy coming online shows a pink " is + logged in." card, unless they are in the player's guild; one moving to + the player's channel shows "You're on the same ch. with ."; - capacity; - - requests, shown as a fading notice (`UIInviteCard`, see - [client.md](client.md)); O accepts and X declines; - - errors, shown as a `UIOk`. + - requests (9), added to the list and shown as a fading notice + (`UIInviteCard`, see [client.md](client.md)); O accepts and X declines; + - errors, shown as a `UIOk`, and modes 0x10, 0x11, 0x13 and 0x16, whose + text the server supplies. +- `IO/BuddyCommands` makes v83's checks before asking the server, each + refusal a `UIOk`: adding an invalid name, a buddy already in that group, + or yourself; accepting someone who didn't ask; removing someone not on + the list. After each map loads it shows the unanswered requests again, + unless a request card is still up. - `MultiChatHandler` prints buddy chat in orange. - The Friend tab of `UIUserList` is built from `UserList/Friend` art: - **ONLINE** (`friend0`), over a `SAME CH.` / `OTHER CH.` legend @@ -76,8 +89,6 @@ entry is the 39-byte `GW_Friend` record. Its channel is zero-based, or -1. - Raising the capacity (packet 0x15 is parsed but never sent), and the Cash Shop flags. - The MAP button, notes, and the blacklist. -- v83 also records a request as a pending entry in the list, says so in the - chat log, and shows its notices again after a map change. ## Unverified against period footage diff --git a/agent_docs/client.md b/agent_docs/client.md index 3fb98e9c9..c5c84cdac 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -106,7 +106,10 @@ from `UI.nx`. Moved so far: requests, trade requests, and party invites, each "from ''" beside its kind's icon (FUN_0052065d). O accepts and X declines; a card left alone fades out after three minutes and sends nothing. A new invitation - replaces a card of its kind, except buddy requests, which pile up. Each + replaces a card of its kind, except buddy requests, which pile up. The + smaller pink cards (`backgrnd2`, FUN_00523a2c) say " is logged + in." or "You're on the same ch. with ." for six seconds, with only + an X. Each card, quest completions included, sits 5 px above the one before it (`FadeNotice::raised`), and only the newest can be clicked. Not done: the cash trade card, holding cards back while the UI is locked, and the Game diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index 16f99318b..b62a61330 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -112,6 +112,8 @@ pub struct Trace { pub party_chat: Vec<(String, String)>, /// Every full BUDDYLIST: (id, name, channel) per buddy. pub buddy_lists: Vec>, + /// The unanswered requests each full BUDDYLIST carries, by requester. + pub buddy_list_requests: Vec>, /// Buddy requests: (requester id, name). pub buddy_requests: Vec<(i32, String)>, /// Buddy channel changes: (buddy, channel or -1). @@ -386,17 +388,21 @@ impl Trace { match r.read_byte().unwrap() { 7 => { let count = r.read_byte().unwrap(); - let list = (0..count) - .map(|_| { - let id = r.read_int().unwrap(); - let name = read_padded(&mut r, 13); - r.read_byte().unwrap(); - let channel = r.read_int().unwrap(); - read_padded(&mut r, 17); - (id, name, channel) - }) - .collect(); + let (mut list, mut requests) = (Vec::new(), Vec::new()); + for _ in 0..count { + let id = r.read_int().unwrap(); + let name = read_padded(&mut r, 13); + let requesting = r.read_byte().unwrap() == 1; + let channel = r.read_int().unwrap(); + read_padded(&mut r, 17); + if requesting { + requests.push(id); + } else { + list.push((id, name, channel)); + } + } self.buddy_lists.push(list); + self.buddy_list_requests.push(requests); } 9 => { let id = r.read_int().unwrap(); diff --git a/integration-harness/tests/buddy_list.rs b/integration-harness/tests/buddy_list.rs index a806ae24c..001dc6f9c 100644 --- a/integration-harness/tests/buddy_list.rs +++ b/integration-harness/tests/buddy_list.rs @@ -78,6 +78,17 @@ async fn buddies_see_each_others_channel_once_both_agree() { until(&mut alice.connection, &mut a, |t| t.buddy_errors.len() == 2).await; assert_eq!(a.buddy_errors[1], ALREADY_ADDED); + // Logging in again carries the request in the list, for the client to + // ask about once the map has loaded, rather than asking again. + drop(bobby); + let mut bobby = relogin(&bobby_config).await; + let mut b = Trace::default(); + until(&mut bobby.connection, &mut b, |t| !t.buddy_lists.is_empty()).await; + assert!(b.buddy_lists[0].is_empty()); + assert_eq!(b.buddy_list_requests[0], [alice_id]); + read_for(&mut bobby.connection, &mut b, 300).await; + assert!(b.buddy_requests.is_empty()); + // Accepting shows both of them the other's channel. send(&mut bobby.connection, with_id(2, alice_id)).await; until(&mut bobby.connection, &mut b, |t| { diff --git a/net/src/packet/build/world/buddy.rs b/net/src/packet/build/world/buddy.rs index 82d639a77..0d881084e 100644 --- a/net/src/packet/build/world/buddy.rs +++ b/net/src/packet/build/world/buddy.rs @@ -36,6 +36,9 @@ pub struct BuddyView { pub group: String, /// Zero-based channel, or `None` while offline or not mutual. pub channel: Option, + /// They asked to be added and haven't been answered. The client keeps + /// the entry, hidden, and asks about it again after each map change. + pub requesting: bool, } fn buddy_list(op: u8) -> Result { @@ -50,14 +53,10 @@ fn channel_int(channel: Option) -> i32 { } /// The 39-byte `GW_Friend` record. -fn write_entry( - packet: &mut Packet, - buddy: &BuddyView, - requesting: bool, -) -> Result<(), NetworkError> { +fn write_entry(packet: &mut Packet, buddy: &BuddyView) -> Result<(), NetworkError> { packet.write_int(buddy.id)?; write_padded_name(packet, &buddy.name)?; - packet.write_byte(u8::from(requesting))?; + packet.write_byte(u8::from(buddy.requesting))?; packet.write_int(channel_int(buddy.channel))?; let group = buddy.group.as_bytes(); let group = &group[..group.len().min(GROUP_FIELD_LEN - 1)]; @@ -71,7 +70,7 @@ pub fn build_buddy_list(buddies: &[BuddyView]) -> Result { let mut packet = buddy_list(OP_LIST)?; packet.write_byte(buddies.len() as u8)?; for buddy in buddies { - write_entry(&mut packet, buddy, false)?; + write_entry(&mut packet, buddy)?; } // Whether each buddy is in the Cash Shop. for _ in buddies { @@ -94,8 +93,9 @@ pub fn build_buddy_request( name: from_name.into(), group: "Default Group".into(), channel, + requesting: true, }; - write_entry(&mut packet, &entry, true)?; + write_entry(&mut packet, &entry)?; packet.write_byte(0)?; // in the Cash Shop Ok(packet) } @@ -147,12 +147,14 @@ mod tests { name: "Bobby".into(), group: "Default Group".into(), channel: Some(2), + requesting: false, }, BuddyView { id: 9, name: "Carol".into(), group: "A group name that is far too long".into(), channel: None, + requesting: true, }, ]; let packet = build_buddy_list(&buddies).unwrap(); @@ -167,7 +169,7 @@ mod tests { expect .int(9) .bytes(&padded("Carol", 13)) - .byte(0) + .byte(1) .int(-1) .bytes(&padded("A group name tha", 17)); expect.int(0).int(0).end(); diff --git a/runtime/src/actor/client/buddies.rs b/runtime/src/actor/client/buddies.rs index a01d276d8..b5d09f2f1 100644 --- a/runtime/src/actor/client/buddies.rs +++ b/runtime/src/actor/client/buddies.rs @@ -20,9 +20,10 @@ fn built(packet: Result) -> Result { } impl ClientActor { - /// On entering the game: show the list, tell mutual buddies we're on - /// this channel, and ask about the first unanswered request. The client - /// starts with an empty list, so nothing is sent for one. + /// On entering the game: show the list, unanswered requests included, + /// and tell mutual buddies we're on this channel. The client asks about + /// each request once the map has loaded. It starts with an empty list, + /// so nothing is sent for one. pub(super) async fn load_buddies(&mut self) -> Result<(), RuntimeError> { let rows = self.buddy_rows().await?; if rows.is_empty() { @@ -30,7 +31,7 @@ impl ClientActor { } let mutual = rows.iter().filter(|b| b.mutual).map(|b| b.id).collect(); self.announce_channel(mutual, Some(self.channel_id)).await?; - self.show_buddies(rows, true).await + self.show_buddies(rows).await } /// On leaving the game (not a channel change, which announces the new @@ -55,7 +56,7 @@ impl ClientActor { .await?; } let rows = self.buddy_rows().await?; - self.show_buddies(rows, true).await?; + self.show_buddies(rows).await?; } BuddyRequest::Remove { id } => { let removed = spawn_db(move || db::buddy::remove(cid, id)).await?; @@ -63,9 +64,8 @@ impl ClientActor { // Whether or not they still list us, they no longer see us. self.announce_channel(vec![id], None).await?; } - // Declining a request moves on to the next one. let rows = self.buddy_rows().await?; - self.show_buddies(rows, removed == Some(true)).await?; + self.show_buddies(rows).await?; } BuddyRequest::Chat { recipients, text } => { let mutual = spawn_db(move || db::buddy::mutual_ids(cid)).await?; @@ -117,7 +117,7 @@ impl ClientActor { }) .await?; let rows = self.buddy_rows().await?; - self.show_buddies(rows, false).await + self.show_buddies(rows).await } async fn buddy_rows(&self) -> Result, RuntimeError> { @@ -125,29 +125,24 @@ impl ClientActor { spawn_db(move || db::buddy::list(cid)).await } - /// Send the accepted rows through the directory, which knows channels, - /// and if `ask`, the first unanswered request. - async fn show_buddies(&mut self, rows: Vec, ask: bool) -> Result<(), RuntimeError> { - let (requests, listed): (Vec<_>, Vec<_>) = rows.into_iter().partition(|b| b.pending); - let buddies = listed + /// Send the rows through the directory, which knows channels. v83 keeps + /// the requests in the list too, hidden, so that answering one finds it. + async fn show_buddies(&mut self, rows: Vec) -> Result<(), RuntimeError> { + let buddies = rows .into_iter() .map(|buddy| ListedBuddy { id: buddy.id, name: buddy.name, group: buddy.group, mutual: buddy.mutual, + pending: buddy.pending, }) .collect(); self.send_to_directory(DirectoryMessage::BuddyList { owner: self.client_id, buddies, }) - .await?; - if let Some(request) = requests.first().filter(|_| ask) { - let mut packet = built(build_buddy_request(request.id, &request.name, None))?; - self.send(&mut packet).await?; - } - Ok(()) + .await } async fn announce_channel( diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index 2181fde0e..792174a9c 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -340,6 +340,7 @@ impl WorldDirectory { id: buddy.id, name: buddy.name, group: buddy.group, + requesting: buddy.pending, }) .collect(); self.send_packet(owner, build_buddy_list(&views)); @@ -893,6 +894,7 @@ mod tests { name: name.into(), group: "Default Group".into(), mutual, + pending: false, }; directory.handle(DirectoryMessage::BuddyList { owner: 1, diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 683415163..61910c79e 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -331,6 +331,8 @@ pub struct ListedBuddy { pub group: String, /// They list the owner too, so the owner may see their channel. pub mutual: bool, + /// They asked to be added and the owner hasn't answered. + pub pending: bool, } /// Messages to the world directory. From 7f0ba95070921ad0b6d8e69216102b801e80d642 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 03:20:12 -0400 Subject: [PATCH 19/23] L182: v83 quest conversation sequencing HeavenClient now plays a quest's Say.img pages the way v83's FUN_00716fe1 does. Before a completion says anything, Check/1 is tested in v83's order (NPC, items, quests, kills); a failure shows stop/, then stop/default, then the NPC's own d0 line, and sends nothing. At the wrong NPC the lost pages replace stop/npc when a quest item given at the start is gone. Question pages (ask) have no Prev. A choice with a stop// text is wrong: the text shows with OK and the conversation ends. Every other choice is right, since v83 never reads answer. With stop//flip, the lead text is a page of its own before the choices. The yes pages now wait for the server's UPDATE_QUEST_INFO mode 8, and the next quest opens only once they have been read to the end. The last completion page carries v83's reward list (FUN_0071808a), built by QuestRewards from Act/1: fixed items filtered by job and gender, meso, EXP, and the random-item box, left out when another quest follows. #w no longer means white. It marks text that UINpcTalk shows at once while the rest types out. #v and #t also accept v83's "id:" form. GUI-verified: Athena Pierce's 28223 with its reward list and yes page; Rain's 1009 quiz, both wrong and right, chaining into 1010 for a beginner; and Pio's stop/item page. --- HeavenClient/Character/QuestCatalog.cpp | 47 ++- HeavenClient/Character/QuestDialogue.h | 333 +++++++++--------- HeavenClient/Character/QuestInfo.h | 8 +- HeavenClient/Character/QuestMarker.h | 38 ++ HeavenClient/Character/QuestRewards.h | 81 +++++ HeavenClient/Gameplay/QuestConversation.cpp | 149 +++++--- HeavenClient/Gameplay/QuestConversation.h | 7 +- HeavenClient/Graphics/TextLayout.cpp | 3 +- HeavenClient/IO/UITypes/UINpcTalk.cpp | 28 +- HeavenClient/IO/UITypes/UINpcTalk.h | 6 +- .../Net/Handlers/NpcInteractionHandlers.cpp | 3 +- HeavenClient/Util/NpcTextMarkup.h | 8 +- HeavenClient/tests/npc_text.cpp | 6 +- HeavenClient/tests/quest_catalog.cpp | 29 +- HeavenClient/tests/quest_dialogue.cpp | 196 +++++++---- HeavenClient/tests/quest_marker.cpp | 17 + agent_docs/quests.md | 46 ++- 17 files changed, 673 insertions(+), 332 deletions(-) create mode 100644 HeavenClient/Character/QuestRewards.h diff --git a/HeavenClient/Character/QuestCatalog.cpp b/HeavenClient/Character/QuestCatalog.cpp index 93bbe6ac7..e6b430850 100644 --- a/HeavenClient/Character/QuestCatalog.cpp +++ b/HeavenClient/Character/QuestCatalog.cpp @@ -49,7 +49,8 @@ namespace ms info.end_npc = static_cast(end["npc"].get_integer(info.start_npc)); info.min_level = static_cast(start["lvmin"].get_integer(0)); info.max_level = static_cast(start["lvmax"].get_integer(32767)); - info.exp = static_cast(act["exp"].get_integer()); + info.rewards.meso = static_cast(act["money"].get_integer()); + info.rewards.exp = static_cast(act["exp"].get_integer()); info.next_quest = static_cast(act["nextQuest"].get_integer()); info.start_script = start["startscript"].data_type() == nl::node::type::string; info.end_script = end["endscript"].data_type() == nl::node::type::string; @@ -66,6 +67,20 @@ namespace ms info.end_items.emplace_back(static_cast(item["id"].get_integer()), static_cast(item["count"].get_integer())); + for (auto prerequisite : end["quest"]) + info.end_prerequisites.emplace_back(static_cast(prerequisite["id"].get_integer()), + static_cast(prerequisite["state"].get_integer())); + + for (auto item : root["Act.img"][name]["0"]["item"]) + if (item["count"].get_integer() > 0) + info.start_items.emplace_back(static_cast(item["id"].get_integer()), + static_cast(item["count"].get_integer())); + + for (auto item : act["item"]) + info.rewards.items.push_back({ static_cast(item["id"].get_integer()), + static_cast(item["count"].get_integer()), static_cast(item["job"].get_integer(-1)), + static_cast(item["gender"].get_integer(2)), static_cast(item["prop"].get_integer()) }); + for (auto mob : end["mob"]) info.end_mobs.emplace_back(static_cast(mob["id"].get_integer()), static_cast(mob["count"].get_integer(1))); @@ -121,39 +136,23 @@ namespace ms script.pages = pages(say); script.yes = pages(say["yes"]); script.no = pages(say["no"]); + script.ask = say["ask"].get_integer() != 0; + script.lost = pages(say["lost"]); for (auto stop : say["stop"]) { - if (stop.name() == "npc") - { - script.reminder = pages(stop); - continue; - } - - if (stop.name() == "item") - { - script.item_reminder = pages(stop); - continue; - } - - if (stop.name() == "mob") + if (!is_number(stop.name())) { - script.mob_reminder = pages(stop); + script.stops[stop.name()] = pages(stop); continue; } - if (!is_number(stop.name())) - continue; - - auto page = static_cast(std::stoul(stop.name())); - - // The answer is one-based; #L ids are zero-based. - if (stop["answer"].data_type() == nl::node::type::integer) - script.answers[page] = static_cast(stop["answer"].get_integer()) - 1; + auto& question = script.questions[static_cast(std::stoul(stop.name()))]; + question.flip = stop["flip"].get_integer() != 0; for (auto reason : stop) if (is_number(reason.name()) && reason.data_type() == nl::node::type::string) - script.wrong[page][std::stoi(reason.name())] = reason.get_string(); + question.wrong[std::stoi(reason.name())] = reason.get_string(); } return script; diff --git a/HeavenClient/Character/QuestDialogue.h b/HeavenClient/Character/QuestDialogue.h index 5e62bf0f5..3c53e036e 100644 --- a/HeavenClient/Character/QuestDialogue.h +++ b/HeavenClient/Character/QuestDialogue.h @@ -11,24 +11,47 @@ namespace ms // One side of a quest conversation from Quest.nx Say.img//<0|1>. struct QuestScript { + // A question's stop/. + struct Question + { + // stop//: why a choice is wrong. A choice without + // one is right; `answer` means nothing to v83. + std::map wrong; + // stop//flip: the text before the choices is a page of + // its own. + bool flip = false; + }; + // Numbered pages, in order. std::vector pages; - // Shown after the quest was accepted (offer) or completed (completion). + // Shown once the server says the quest was started (offer side) or + // completed (completion side). std::vector yes; // Shown after the offer was declined. std::vector no; - // stop//answer: the right choice for a question page (choice id). - std::map answers; - // stop//: why a wrong choice is wrong. - std::map> wrong; - // stop/npc on the completion side: directions while the quest is open. - std::vector reminder; - // stop/item on the completion side: what to say while the required - // items are missing (or, for Roger's apples, not yet used up). - std::vector item_reminder; - // stop/mob on the completion side: what to say while the required - // mobs have not all been hunted. - std::vector mob_reminder; + // `ask`: every page but the last is a question. + bool ask = false; + std::map questions; + // stop/ (npc, item, quest, mob, default) on the completion + // side: why the quest can't be completed here, or yet. + std::map> stops; + // Said instead of stop/npc when a quest item given at the start was + // lost. + std::vector lost; + + // stop/, else stop/default, as v83 picks them (FUN_00718b58). + std::vector stop(const std::string& reason) const + { + for (const std::string& key : { reason, std::string("default") }) + { + auto pages = stops.find(key); + + if (pages != stops.end() && !pages->second.empty()) + return pages->second; + } + + return {}; + } }; struct QuestPage @@ -42,26 +65,46 @@ namespace ms }; // The client side of the v83 quest flow, independent of UI and assets: - // it pages through the conversation and says when to send QUEST_ACTION. + // it pages through the conversation and says when to send QUEST_ACTION + // (FUN_00716fe1). Nothing is said after the action; the server's answer + // brings the yes pages. class QuestDialogue { public: - enum class Mode { OFFER, COMPLETION, REMINDER }; + enum class Mode { OFFER, COMPLETION, PAGES }; enum class Effect { NONE, START, COMPLETE }; - QuestDialogue(Mode mode, QuestScript script) : mode(mode), script(std::move(script)) + // `rewards` goes after the last completion page (FUN_0071808a). + QuestDialogue(Mode mode, QuestScript script, std::string rewards = {}) + : mode(mode), script(std::move(script)), rewards(std::move(rewards)) { - if (mode == Mode::REMINDER) - enter(Branch::REMINDER); - else if (pages().empty()) + bool silent = mode == Mode::OFFER ? this->script.pages.empty() + : mode == Mode::COMPLETION && this->script.pages.size() <= 1 && last_page().empty(); + + if (silent) { // Nothing to say: the quest action happens right away. initial = mode == Mode::OFFER ? Effect::START : Effect::COMPLETE; - acted = true; - enter(Branch::YES); + step = Step::DONE; } else - enter(Branch::MAIN); + { + // Rewards with no pages make a last page of their own. + if (mode == Mode::COMPLETION && this->script.pages.empty()) + this->script.pages.emplace_back(); + + enter_page(0); + } + } + + // Just pages to read: stop pages, or what the NPC says once a quest + // is started or completed. + static QuestDialogue reading(std::vector pages) + { + QuestScript script; + script.pages = std::move(pages); + + return QuestDialogue(Mode::PAGES, std::move(script)); } // An action to send as soon as the dialogue opens. @@ -75,228 +118,200 @@ namespace ms // The page to show, or nothing once the dialogue is over. std::optional page() const { - if (branch == Branch::DONE) - return std::nullopt; + const auto& pages = script.pages; - if (branch == Branch::WRONG) - return QuestPage { QuestPage::TEXT, wrong_text, false, true }; + switch (step) + { + case Step::DONE: + return std::nullopt; + case Step::WRONG: + return QuestPage { QuestPage::TEXT, wrong_text, false, false }; + case Step::DECLINED: + return QuestPage { QuestPage::TEXT, script.no[index], index > 0, index + 1 < script.no.size() }; + case Step::FLIP: + return QuestPage { QuestPage::TEXT, pages[index].substr(0, pages[index].find(CHOICES)), index > 0, true }; + case Step::QUESTION: + { + std::string text = pages[index]; - const auto& list = current(); - const std::string& text = list[index]; + if (flipped()) + text = "\r\n" + text.substr(text.find(CHOICES)); - if (branch == Branch::MAIN) - { - if (is_question(index)) return QuestPage { QuestPage::SELECTION, text, false, false }; - if (mode == Mode::OFFER && index + 1 == list.size()) - return QuestPage { QuestPage::YESNO, text, false, false }; + } + case Step::PAGE: + break; } - bool has_next = index + 1 < list.size() || - (branch == Branch::MAIN && mode == Mode::COMPLETION && !script.yes.empty()); + if (mode == Mode::PAGES || index + 1 < pages.size()) + return QuestPage { QuestPage::TEXT, pages[index], index > 0, index + 1 < pages.size() }; - // Prev never returns to (or past) an answered question. - bool has_previous = index > 0 && !(branch == Branch::MAIN && index <= first_after_question()); + if (mode == Mode::OFFER) + return QuestPage { QuestPage::YESNO, pages[index], false, false }; - return QuestPage { QuestPage::TEXT, text, has_previous, has_next }; + return QuestPage { QuestPage::TEXT, last_page(), false, false }; } // response: 1 next/yes/select, 0 previous/no, -1 close. `selection` is // the chosen #L id on a question page. Effect respond(int8_t response, int32_t selection = -1) { - if (branch == Branch::DONE) - return Effect::NONE; + auto shown = page(); - if (response < 0) - { - branch = Branch::DONE; + if (!shown) return Effect::NONE; - } - if (branch == Branch::WRONG) + if (response < 0 || step == Step::WRONG) { - // Back to the same question. - branch = Branch::MAIN; + step = Step::DONE; return Effect::NONE; } - if (branch != Branch::MAIN) - { - page_through(response); - return Effect::NONE; - } - - auto shown = page(); - - if (shown->kind == QuestPage::YESNO) + if (step == Step::FLIP) { + // Prev from the flipped page ends the conversation. if (response == 1) - { - acted = true; - enter(Branch::YES); - return Effect::START; - } + step = Step::QUESTION; + else + step = Step::DONE; - enter(Branch::NO); return Effect::NONE; } - if (shown->kind == QuestPage::SELECTION) + if (step == Step::QUESTION) { - if (response != 1) - return Effect::NONE; + auto question = script.questions.find(index); - auto answer = script.answers.find(index); - - if (answer != script.answers.end() && answer->second != selection) + if (response != 1 || selection < 0 || question == script.questions.end()) { - auto reasons = script.wrong.find(index); - - if (reasons != script.wrong.end() && reasons->second.count(selection)) - { - wrong_text = reasons->second.at(selection); - branch = Branch::WRONG; - } - + step = Step::DONE; return Effect::NONE; } - // Passing the last question completes the quest; what follows - // is the NPC congratulating the player. - Effect effect = Effect::NONE; + auto wrong = question->second.wrong.find(selection); - if (mode == Mode::COMPLETION && !acted && index == last_question()) + if (wrong != question->second.wrong.end() && !wrong->second.empty()) { - acted = true; - effect = Effect::COMPLETE; + // The quest is not started or completed; the player can + // talk to the NPC again. + wrong_text = wrong->second; + step = Step::WRONG; } - else if (mode == Mode::OFFER && !acted && index + 1 == script.pages.size()) + else { - // An offer that ends in a question is accepted by answering it. - acted = true; - effect = Effect::START; + enter_page(index + 1); } - advance(); - - return effect; + return Effect::NONE; } - if (response == 0) + if (step == Step::DECLINED) { - if (shown->previous) + if (response == 0 && shown->previous) index--; + else if (response == 1 && shown->next) + index++; + else + step = Step::DONE; return Effect::NONE; } - if (index + 1 < script.pages.size()) + if (shown->kind == QuestPage::YESNO) { - index++; + if (response == 1) + { + step = Step::DONE; + return Effect::START; + } + + step = Step::DECLINED; + index = 0; + + if (script.no.empty()) + step = Step::DONE; + return Effect::NONE; } - // Leaving the last completion page. - Effect effect = Effect::NONE; + if (response == 0) + { + if (shown->previous) + enter_page(index - 1); + else + step = Step::DONE; + + return Effect::NONE; + } - if (mode == Mode::COMPLETION && !acted) + if (shown->next) { - acted = true; - effect = Effect::COMPLETE; + enter_page(index + 1); + return Effect::NONE; } - enter(Branch::YES); + // OK on the last page. + step = Step::DONE; + read_to_end = true; - return effect; + return mode == Mode::COMPLETION ? Effect::COMPLETE : Effect::NONE; } bool finished() const { - return branch == Branch::DONE; + return step == Step::DONE; } - private: - enum class Branch { MAIN, YES, NO, WRONG, REMINDER, DONE }; - - const std::vector& pages() const + // Whether the player read every page and left with OK. + bool read() const { - return script.pages; - } - - const std::vector& current() const - { - switch (branch) - { - case Branch::YES: return script.yes; - case Branch::NO: return script.no; - case Branch::REMINDER: return script.reminder; - default: return script.pages; - } + return read_to_end; } - void enter(Branch next) - { - branch = next; - index = 0; + private: + enum class Step { PAGE, FLIP, QUESTION, WRONG, DECLINED, DONE }; - if (branch != Branch::DONE && current().empty()) - branch = Branch::DONE; - } + static constexpr const char* CHOICES = "#L0#"; - void advance() + // The last page as shown: on the completion side, with the rewards. + std::string last_page() const { - if (index + 1 < script.pages.size()) - index++; - else - enter(Branch::YES); - } + std::string text = script.pages.empty() ? std::string() : script.pages.back(); - void page_through(int8_t response) - { - if (response == 0) - { - if (index > 0) - index--; - } - else if (index + 1 < current().size()) - index++; - else - branch = Branch::DONE; + return mode == Mode::COMPLETION ? text + rewards : text; } - bool is_question(size_t page) const + bool question(size_t page) const { - return script.answers.count(page) || - (page < script.pages.size() && script.pages[page].find("#L") != std::string::npos); + return mode != Mode::PAGES && script.ask && page + 1 < script.pages.size(); } - // The first page after the last question, or 0 if there is none. - size_t first_after_question() const + bool flipped() const { - for (size_t i = script.pages.size(); i > 0; i--) - if (is_question(i - 1)) - return i; + auto question = script.questions.find(index); - return 0; + return question != script.questions.end() && question->second.flip && + script.pages[index].find(CHOICES) != std::string::npos; } - size_t last_question() const + void enter_page(size_t page) { - size_t last = 0; + index = page; - for (size_t i = 0; i < script.pages.size(); i++) - if (is_question(i)) - last = i; - - return last; + if (page >= script.pages.size()) + step = Step::DONE; + else if (!question(page)) + step = Step::PAGE; + else + step = flipped() ? Step::FLIP : Step::QUESTION; } Mode mode; QuestScript script; - Branch branch = Branch::MAIN; + std::string rewards; + Step step = Step::PAGE; size_t index = 0; - bool acted = false; + bool read_to_end = false; Effect initial = Effect::NONE; std::string wrong_text; }; diff --git a/HeavenClient/Character/QuestInfo.h b/HeavenClient/Character/QuestInfo.h index cd1757187..2fd78d971 100644 --- a/HeavenClient/Character/QuestInfo.h +++ b/HeavenClient/Character/QuestInfo.h @@ -1,5 +1,7 @@ #pragma once +#include "QuestRewards.h" + #include #include #include @@ -22,12 +24,16 @@ namespace ms // Items needed to complete: (item id, count); a count of 0 means none // may be left. std::vector> end_items; + // Quests in a state needed to complete this one, as above. + std::vector> end_prerequisites; + // Items given when the quest starts: (item id, count). + std::vector> start_items; // Mobs to kill before completing: (mob id, count). std::vector> end_mobs; // The server runs these steps as scripts (QUEST_ACTION 4/5). bool start_script = false; bool end_script = false; - int32_t exp = 0; + QuestRewards rewards; int16_t next_quest = 0; }; } diff --git a/HeavenClient/Character/QuestMarker.h b/HeavenClient/Character/QuestMarker.h index 7fdf48934..aeba6b1ff 100644 --- a/HeavenClient/Character/QuestMarker.h +++ b/HeavenClient/Character/QuestMarker.h @@ -50,6 +50,44 @@ namespace ms }); } + // Why a started quest can't be completed with an NPC, by v83's first + // failed check (FUN_00721d2c), or NONE. The names are the stop/ + // pages they show. + enum class QuestStop { NONE, NPC, ITEM, QUEST, MOB }; + + inline const char* stop_reason(QuestStop stop) + { + switch (stop) + { + case QuestStop::NPC: return "npc"; + case QuestStop::ITEM: return "item"; + case QuestStop::QUEST: return "quest"; + case QuestStop::MOB: return "mob"; + default: return "default"; + } + } + + // `progress` is the quest's progress string (its kill counts). + template + QuestStop completion_stop(const QuestInfo& quest, int32_t npc_id, Status status, Count count, + const std::string& progress) + { + if (quest.end_npc != npc_id) + return QuestStop::NPC; + + if (!end_items_met(quest, count)) + return QuestStop::ITEM; + + for (const auto& requirement : quest.end_prerequisites) + if (status(requirement.first) != requirement.second) + return QuestStop::QUEST; + + if (!end_mobs_met(quest, progress)) + return QuestStop::MOB; + + return QuestStop::NONE; + } + template bool quest_available(const QuestInfo& quest, int16_t job, int16_t level, Status status, bool all_levels = false) diff --git a/HeavenClient/Character/QuestRewards.h b/HeavenClient/Character/QuestRewards.h new file mode 100644 index 000000000..6659d50f8 --- /dev/null +++ b/HeavenClient/Character/QuestRewards.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +namespace ms +{ + // Act.img//1: what completing a quest gives, as far as the server + // supports it (no skills, fame, closeness or items to choose from). + struct QuestRewards + { + struct Item + { + int32_t id; + int32_t count; + // A bit per job branch (1 << job / 100); -1 is every job. + int32_t jobs = -1; + // 0 male, 1 female, 2 either. + int32_t gender = 2; + // 0 always given; above 0, one of these is given at random. + int32_t prop = 0; + + bool applies_to(int16_t job, bool female) const + { + int32_t branch = job / 100; + + return ((jobs & (1 << branch)) != 0 || branch == 9) && (gender == 2 || gender == (female ? 1 : 0)); + } + }; + + int32_t meso = 0; + int32_t exp = 0; + std::vector items; + + // The list v83 puts under the last completion page (FUN_0071808a), + // or nothing when there is nothing to list or another quest follows. + // "#w" shows it whole while the page above types out. + std::string summary(int16_t job, bool female, bool next_quest) const + { + std::string given; + bool random = false; + + for (const Item& item : items) + { + if (item.count <= 0 || !item.applies_to(job, female)) + continue; + + if (item.prop > 0) + { + random = true; + } + else + { + std::string id = std::to_string(item.id); + given += "#v" + id + ":# #t" + id + ":# " + std::to_string(item.count) + "\r\n"; + } + } + + if (next_quest || (given.empty() && !random && meso <= 0 && exp <= 0)) + return {}; + + std::string text = "#w\r\n\r\n#fUI/UIWindow.img/QuestIcon/4/0#\r\n" + given + "\r\n"; + + if (meso > 0) + text += "#fUI/UIWindow.img/QuestIcon/7/0# " + std::to_string(meso) + " Meso\r\n"; + + if (exp > 0) + text += "#fUI/UIWindow.img/QuestIcon/8/0# " + std::to_string(exp) + " exp\r\n"; + + if (meso > 0 || exp > 0) + text += "\r\n"; + + // Which random item is given is up to the server; v83 names none. + if (random) + text += "#FUI/UIWindow.img/QuestIcon/5/0#\r\n"; + + return text + "#w"; + } + }; +} diff --git a/HeavenClient/Gameplay/QuestConversation.cpp b/HeavenClient/Gameplay/QuestConversation.cpp index 3c06ce4e9..c5b62e57b 100644 --- a/HeavenClient/Gameplay/QuestConversation.cpp +++ b/HeavenClient/Gameplay/QuestConversation.cpp @@ -5,6 +5,7 @@ #include "../Character/QuestCatalog.h" #include "../Character/QuestDialogue.h" #include "../Character/QuestMarker.h" +#include "../Data/ItemData.h" #include "../IO/UI.h" #include "../IO/UITypes/UINpcTalk.h" #include "../Net/Packets/NpcInteractionPackets.h" @@ -31,11 +32,11 @@ namespace ms QuestDialogue dialogue; int16_t quest_id; int32_t npc_id; + // Opens once the dialogue has been read to the end. + int16_t next_quest = 0; }; std::optional active; - // A follow-up quest to open once the current conversation ends. - std::optional> next; // Menu choice for the NPC's normal speech; quest ids are positive. constexpr int32_t TALK_CHOICE = 0; @@ -58,19 +59,50 @@ namespace ms [](int16_t id) { return state(id); }); } - bool items_met(const QuestInfo& quest) + QuestStop completion_stop(const QuestInfo& quest, int32_t npc_id) { const Inventory& inventory = player().get_inventory(); + const auto& started = player().get_quests().started_quests(); + auto progress = started.find(quest.id); - return end_items_met(quest, [&](int32_t id) { return inventory.get_total_item_count(id); }); + return ms::completion_stop(quest, npc_id, [](int16_t id) { return state(id); }, + [&](int32_t id) { return inventory.get_total_item_count(id); }, + progress == started.end() ? std::string() : progress->second); } - bool mobs_met(const QuestInfo& quest) + // Whether a quest item given at the start is no longer held, worn + // or not (FUN_00718b58). + bool lost_start_items(const QuestInfo& quest) { - const auto& started = player().get_quests().started_quests(); - auto iter = started.find(quest.id); + const Inventory& inventory = player().get_inventory(); - return end_mobs_met(quest, iter == started.end() ? std::string() : iter->second); + return std::any_of(quest.start_items.begin(), quest.start_items.end(), [&](const auto& item) + { + int32_t held = inventory.get_total_item_count(item.first) + + inventory.count_slots(InventoryType::Id::EQUIPPED, item.first); + + return ItemData::get(item.first).is_questitem() && held < item.second; + }); + } + + // What the NPC says when a quest can't be completed with it, or + // yet: the NPC's own d0 line when the quest has nothing to say. + std::vector stop_pages(const QuestScript& script, QuestStop stop, bool lost, int32_t npc_id) + { + if (lost && !script.lost.empty()) + return script.lost; + + std::vector pages = script.stop(stop_reason(stop)); + + if (pages.empty()) + { + std::string line = nl::nx::string["Npc.img"][std::to_string(npc_id)]["d0"]; + + if (!line.empty()) + pages.push_back(line); + } + + return pages; } // Whether the server has normal speech for this NPC (info/speak). @@ -103,18 +135,29 @@ namespace ms show(); } + void begin(Active conversation) + { + active = std::move(conversation); + send(active->dialogue.take_initial_effect(), active->quest_id, active->npc_id); + show(); + } + void show() { auto page = active ? active->dialogue.page() : std::nullopt; if (!page) { - active.reset(); - - if (auto followup = next) + if (active && active->dialogue.read() && active->next_quest != 0) + { + int16_t next_quest = active->next_quest; + int32_t npc_id = active->npc_id; + active.reset(); + open(next_quest, npc_id); + } + else { - next.reset(); - open(followup->first, followup->second); + active.reset(); } return; @@ -155,54 +198,65 @@ namespace ms if (!quest) return; - std::optional mode; + if (state(quest_id) == 0) + { + if (quest->start_npc != npc_id || !can_start(*quest)) + return; - if (state(quest_id) == 0 && quest->start_npc == npc_id && can_start(*quest)) - mode = QuestDialogue::Mode::OFFER; - else if (state(quest_id) == 1 && quest->end_npc == npc_id) - mode = QuestDialogue::Mode::COMPLETION; - else if (state(quest_id) == 1 && quest->start_npc == npc_id) - mode = QuestDialogue::Mode::REMINDER; + if (quest->start_script) + { + // The server runs this step as a script and sends its dialogue. + QuestActionPacket(QuestActionPacket::SCRIPT_START, quest_id, npc_id, player().get_position()).dispatch(); + return; + } - if (!mode) + begin({ QuestDialogue(QuestDialogue::Mode::OFFER, QuestCatalog::get().script(quest_id, false)), quest_id, npc_id }); return; + } - bool completion_side = *mode != QuestDialogue::Mode::OFFER; - QuestScript script = QuestCatalog::get().script(quest_id, completion_side); + if (state(quest_id) != 1) + return; - if (*mode == QuestDialogue::Mode::COMPLETION && !mobs_met(*quest)) - { - // Say why it can't be finished yet (stop/mob, else stop/item). - script.reminder = script.mob_reminder.empty() ? script.item_reminder : script.mob_reminder; - mode = QuestDialogue::Mode::REMINDER; - } - else if (*mode == QuestDialogue::Mode::COMPLETION && !items_met(*quest)) + // v83 checks Check.img//1 before saying anything (FUN_00716fe1). + QuestScript script = QuestCatalog::get().script(quest_id, true); + QuestStop stop = completion_stop(*quest, npc_id); + + if (stop != QuestStop::NONE) { - // Say why it can't be finished yet (stop/item). - script.reminder = script.item_reminder; - mode = QuestDialogue::Mode::REMINDER; + bool lost = stop == QuestStop::NPC && lost_start_items(*quest); + begin({ QuestDialogue::reading(stop_pages(script, stop, lost, npc_id)), quest_id, npc_id }); + return; } - else if ((*mode == QuestDialogue::Mode::OFFER && quest->start_script) || - (*mode == QuestDialogue::Mode::COMPLETION && quest->end_script)) + + if (quest->end_script) { - // The server runs this step as a script and sends its dialogue. - auto action = *mode == QuestDialogue::Mode::OFFER - ? QuestActionPacket::SCRIPT_START : QuestActionPacket::SCRIPT_END; - QuestActionPacket(action, quest_id, npc_id, player().get_position()).dispatch(); + QuestActionPacket(QuestActionPacket::SCRIPT_END, quest_id, npc_id, player().get_position()).dispatch(); return; } - active = Active { QuestDialogue(*mode, std::move(script)), quest_id, npc_id }; - send(active->dialogue.take_initial_effect(), quest_id, npc_id); - show(); + auto& stats = player().get_stats(); + std::string rewards = quest->rewards.summary(stats.get_stat(MapleStat::Id::JOB), stats.get_female(), + quest->next_quest != 0); + + begin({ QuestDialogue(QuestDialogue::Mode::COMPLETION, std::move(script), std::move(rewards)), quest_id, npc_id }); } - void continue_with(int16_t quest_id, int32_t npc_id) + void confirmed(int16_t quest_id, int32_t npc_id, int16_t next_quest) { - if (active) - next = std::make_pair(quest_id, npc_id); - else - open(quest_id, npc_id); + // The quest's record arrives first, so a started quest is in + // progress and a completed one is not. + bool completed = state(quest_id) != 1; + QuestScript script = QuestCatalog::get().script(quest_id, completed); + + if (script.yes.empty()) + { + if (next_quest != 0) + open(next_quest, npc_id); + + return; + } + + begin({ QuestDialogue::reading(std::move(script.yes)), quest_id, npc_id, next_quest }); } void talk_to_npc(int32_t npc_id, int32_t object_id) @@ -254,7 +308,6 @@ namespace ms } active.reset(); - next.reset(); UI::get().emplace(); UI::get().enable(); diff --git a/HeavenClient/Gameplay/QuestConversation.h b/HeavenClient/Gameplay/QuestConversation.h index 7174e83c9..328fe96b1 100644 --- a/HeavenClient/Gameplay/QuestConversation.h +++ b/HeavenClient/Gameplay/QuestConversation.h @@ -17,8 +17,9 @@ namespace ms // reminder, depending on the quest's state. void open(int16_t quest_id, int32_t npc_id); - // The server completed a quest whose Act/nextQuest names another one; - // continue into it once the current conversation is over. - void continue_with(int16_t quest_id, int32_t npc_id); + // The server started or completed a quest (0xD3 mode 8): the NPC + // says its yes pages, then `next_quest`, if any, opens once they + // have all been read (FUN_00959a73). + void confirmed(int16_t quest_id, int32_t npc_id, int16_t next_quest); } } diff --git a/HeavenClient/Graphics/TextLayout.cpp b/HeavenClient/Graphics/TextLayout.cpp index 4d25424cc..6e3f1fe02 100644 --- a/HeavenClient/Graphics/TextLayout.cpp +++ b/HeavenClient/Graphics/TextLayout.cpp @@ -151,7 +151,8 @@ namespace ms color = Color::Name::ORANGE; break; case 'w': - color = Color::Name::WHITE; + // v83's "show at once" toggle (FUN_0099dd54); + // the NPC dialog acts on it. break; } diff --git a/HeavenClient/IO/UITypes/UINpcTalk.cpp b/HeavenClient/IO/UITypes/UINpcTalk.cpp index 623b572f4..d732ee64c 100644 --- a/HeavenClient/IO/UITypes/UINpcTalk.cpp +++ b/HeavenClient/IO/UITypes/UINpcTalk.cpp @@ -191,7 +191,7 @@ namespace ms image.texture.draw(box + image.anchor); } - text.draw(origin, clip, revealed(), choice_text); + text.draw(origin, clip, revealed(), instant_text); for (size_t i = 0; i < choices.size(); i++) { @@ -289,8 +289,15 @@ namespace ms timestep += Constants::TIMESTEP; for (; timestep >= TYPE_MS && formatted_text_pos < formatted_text.size(); timestep -= TYPE_MS) + { formatted_text_pos++; + // What already shows is not typed again. + for (const auto &range : instant_text) + if (formatted_text_pos >= range.first && formatted_text_pos < range.second) + formatted_text_pos = range.second; + } + if (formatted_text_pos >= formatted_text.size()) draw_text = false; } @@ -661,7 +668,7 @@ namespace ms menu_kind = menu; images.clear(); choices.clear(); - choice_text.clear(); + instant_text.clear(); formatted_text = format_text(tx, npcid, images); std::vector inline_ids; @@ -731,14 +738,27 @@ namespace ms c = Text::BOX; } else if (c == NpcTextMarkup::CHOICE_END) { - if (choice_text.size() < marks.size()) - choice_text.emplace_back(marks.back().second, i + 1); + if (instant_text.size() < marks.size()) + instant_text.emplace_back(marks.back().second, i + 1); boxes.emplace_back(0, 0); c = Text::BOX; } } + // Each #w turns showing at once on or off (FUN_0099dd54), as for the + // rewards under a quest's last page. + for (size_t on = formatted_text.find("#w"); on != std::string::npos;) + { + size_t off = formatted_text.find("#w", on + 2); + size_t end = off == std::string::npos ? formatted_text.size() : off + 2; + + instant_text.emplace_back(on, end); + on = off == std::string::npos ? off : formatted_text.find("#w", end); + } + + std::sort(instant_text.begin(), instant_text.end()); + text = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::DARKGREY, formatted_text, TEXT_WIDTH); text.set_boxes(boxes); diff --git a/HeavenClient/IO/UITypes/UINpcTalk.h b/HeavenClient/IO/UITypes/UINpcTalk.h index 2497f8042..61e3228d7 100644 --- a/HeavenClient/IO/UITypes/UINpcTalk.h +++ b/HeavenClient/IO/UITypes/UINpcTalk.h @@ -193,9 +193,9 @@ namespace ms }; std::vector choices; - // Inline choices' text, which shows at once while the rest types - // out, as in footage. - std::vector> choice_text; + // Text that shows at once while the rest types out, in order: inline + // choices, as in footage, and what v83 puts between #w marks. + std::vector> instant_text; size_t selected; answer_focus::Focus focus; std::vector images; diff --git a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp index 5e797e397..cbef053a1 100644 --- a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp @@ -92,8 +92,7 @@ namespace ms int32_t npc_id = recv.read_int(); int16_t next_quest = recv.length() >= 2 ? recv.read_short() : 0; - if (next_quest != 0) - QuestConversation::continue_with(next_quest, npc_id); + QuestConversation::confirmed(quest_id, npc_id, next_quest); return; } diff --git a/HeavenClient/Util/NpcTextMarkup.h b/HeavenClient/Util/NpcTextMarkup.h index 07b367bde..10c3d4bf2 100644 --- a/HeavenClient/Util/NpcTextMarkup.h +++ b/HeavenClient/Util/NpcTextMarkup.h @@ -81,8 +81,10 @@ namespace ms size_t close = text.find('#', i + 2); std::string arg = close == std::string::npos ? std::string() : text.substr(i + 2, close - i - 2); - auto id = [&]() { return static_cast(std::stol(arg)); }; - bool numeric = is_digits(arg); + // v83 writes ids as "#v2000002:#" as well as "#v2000002#". + std::string number = !arg.empty() && arg.back() == ':' ? arg.substr(0, arg.size() - 1) : arg; + auto id = [&]() { return static_cast(std::stol(number)); }; + bool numeric = is_digits(number); switch (tag) { @@ -112,7 +114,7 @@ namespace ms case 'i': case 's': // Item and skill icons. - if (numeric) { out += image(tag, arg); i = close + 1; continue; } + if (numeric) { out += image(tag, number); i = close + 1; continue; } break; case 'f': case 'F': diff --git a/HeavenClient/tests/npc_text.cpp b/HeavenClient/tests/npc_text.cpp index 37b0b2e4d..b5dff5d0b 100644 --- a/HeavenClient/tests/npc_text.cpp +++ b/HeavenClient/tests/npc_text.cpp @@ -21,6 +21,7 @@ int main() [](int32_t id) { return id == 10000 ? std::string("Mushroom Town") : std::string("?"); } }; auto expand = [&](const std::string& s) { return ms::NpcTextMarkup::expand(s, lookups); }; + using Images = std::vector; require(expand("#fUI/UIWindow.img/QuestIcon/4/0#\\n#v2010000# 3 #t2010000#\\n#v2010009# 3 #t2010009#") == "\\n 3 Apple\\n 3 Green Apple", "every item tag expands and images are dropped"); @@ -28,7 +29,10 @@ int main() require(expand("Hunt #o100100# in #m10000#, #z2010000#.") == "Hunt Snail in Mushroom Town, Apple.", "mob, map, item names"); require(expand("#bBlue#k and #L0#a choice#l") == "#bBlue#k and #L0#a choice#l", "colours and choices are left alone"); require(expand("a # sign") == "a # sign", "a lone # survives"); - using Images = std::vector; + Images reward; + require(ms::NpcTextMarkup::expand("#v2010000:# #t2010000:# 3 #z2010009:#", lookups, &reward) == "\x01 Apple 3 Green Apple", + "ids may end in a colon"); + require(reward == Images{{'v', "2010000"}}, "the colon is not part of the id"); Images images; require(ms::NpcTextMarkup::expand("#fUI/UIWindow.img/QuestIcon/4/0#\\n#v2010000# 3 #t2010000#\\n#s1001# and #i2010009##fnot an image#", lookups, &images) == "\x01\\n\x01 3 Apple\\n\x01 and \x01#fnot an image#", "captured images leave placeholders"); diff --git a/HeavenClient/tests/quest_catalog.cpp b/HeavenClient/tests/quest_catalog.cpp index c531d64a2..93148adf4 100644 --- a/HeavenClient/tests/quest_catalog.cpp +++ b/HeavenClient/tests/quest_catalog.cpp @@ -68,11 +68,36 @@ int main() return 1; } + // Pio takes back his two items and pays meso and EXP; only those are listed. + if (pio->rewards.summary(0, false, false) != + "#w\r\n\r\n#fUI/UIWindow.img/QuestIcon/4/0#\r\n\r\n" + "#fUI/UIWindow.img/QuestIcon/7/0# 200 Meso\r\n#fUI/UIWindow.img/QuestIcon/8/0# 100 exp\r\n\r\n#w") + { + std::cerr << "Quest 1008's rewards are not listed as expected\n"; + return 1; + } + + // Todd gives beginners one of five hats. + if (todd->rewards.summary(0, false, false).find("QuestIcon/5/0") == std::string::npos || + todd->rewards.summary(100, false, false).find("QuestIcon/5/0") != std::string::npos) + { + std::cerr << "Quest 1035's random hats are not listed as expected\n"; + return 1; + } + ms::QuestScript rain = catalog.script(1009, true); - if (rain.answers.at(0) != 0 || rain.wrong.at(0).count(1) != 1) + if (!rain.ask || rain.questions.at(0).wrong.count(1) != 1 || rain.questions.at(0).wrong.count(0) != 0) + { + std::cerr << "Rain's quiz did not parse as expected\n"; + return 1; + } + + ms::QuestScript lana = catalog.script(2270, true); + + if (!lana.questions.at(0).flip || lana.stop("npc").empty()) { - std::cerr << "Rain's first quiz answer did not parse as expected\n"; + std::cerr << "Lana's questions did not parse as expected\n"; return 1; } diff --git a/HeavenClient/tests/quest_dialogue.cpp b/HeavenClient/tests/quest_dialogue.cpp index e93e944f5..50b02c12f 100644 --- a/HeavenClient/tests/quest_dialogue.cpp +++ b/HeavenClient/tests/quest_dialogue.cpp @@ -1,4 +1,5 @@ #include "../Character/QuestDialogue.h" +#include "../Character/QuestRewards.h" #include #include @@ -17,99 +18,165 @@ void offers_page_then_ask_for_acceptance() ms::QuestScript heena; heena.pages = {"hello", "I am Heena", "go see Sera?"}; heena.yes = {"check the minimap", "Sera is east"}; - heena.no = {"not ready?"}; + heena.no = {"not ready?", "come back later"}; QuestDialogue accept(Mode::OFFER, heena); require(accept.take_initial_effect() == Effect::NONE, "an offer with pages waits for the player"); - require(accept.page()->kind == QuestPage::TEXT && accept.page()->next, "offer pages have Next"); - require(accept.respond(1) == Effect::NONE, "paging does not start the quest"); + require(accept.page()->kind == QuestPage::TEXT && accept.page()->next && !accept.page()->previous, + "the first offer page has only Next"); + require(accept.respond(1) == Effect::NONE && accept.page()->previous, "paging does not start the quest"); require(accept.respond(0) == Effect::NONE && accept.page()->text == "hello", "Prev goes back"); accept.respond(1); accept.respond(1); require(accept.page()->kind == QuestPage::YESNO, "the last offer page asks yes/no"); require(accept.respond(1) == Effect::START, "Yes starts the quest"); - require(accept.page()->text == "check the minimap", "acceptance pages follow"); - accept.respond(1); - require(!accept.page()->next, "the last acceptance page has OK"); - require(accept.respond(1) == Effect::NONE && accept.finished(), "OK closes the dialogue"); + require(accept.finished(), "the yes pages wait for the server"); QuestDialogue decline(Mode::OFFER, heena); decline.respond(1); decline.respond(1); require(decline.respond(0) == Effect::NONE, "No does not start the quest"); - require(decline.page()->text == "not ready?", "decline pages follow"); + require(decline.page()->text == "not ready?" && decline.page()->next, "decline pages follow"); + decline.respond(1); + require(decline.page()->previous && !decline.page()->next, "the last decline page has Prev and OK"); decline.respond(1); require(decline.finished(), "decline closes after its pages"); QuestDialogue closed(Mode::OFFER, heena); require(closed.respond(-1) == Effect::NONE && closed.finished(), "closing an offer does nothing"); + + QuestDialogue silent_offer(Mode::OFFER, ms::QuestScript{}); + require(silent_offer.take_initial_effect() == Effect::START && silent_offer.finished(), + "no offer text starts immediately"); } -void quiz_answers_complete_once() +void wrong_answers_end_the_conversation() { + // Say.img/1009/1: Rain's quiz. ms::QuestScript rain; - rain.pages = {"What opens the inventory?\\n#L0#I#l#L1#K#l", "That's right!!"}; - rain.answers[0] = 0; - rain.wrong[0][1] = "K is for skills"; - - QuestDialogue quiz(Mode::COMPLETION, rain); - require(quiz.page()->kind == QuestPage::SELECTION, "the question is a selection"); - require(quiz.respond(1, 1) == Effect::NONE, "a wrong answer pays nothing"); - require(quiz.page()->text == "K is for skills", "a wrong answer explains why"); - quiz.respond(1); - require(quiz.page()->kind == QuestPage::SELECTION, "the question is asked again"); - require(quiz.respond(1, 7) == Effect::NONE && quiz.page()->kind == QuestPage::SELECTION, - "an answer without an explanation just asks again"); - require(quiz.respond(1, 0) == Effect::COMPLETE, "the right answer completes the quest"); - require(quiz.page()->text == "That's right!!", "congratulations follow"); - require(!quiz.page()->previous, "no going back to an answered question"); - require(quiz.respond(0) == Effect::NONE && quiz.page()->text == "That's right!!", "Prev is refused"); - require(quiz.respond(1) == Effect::NONE && quiz.finished(), "the quest completes only once"); + rain.ask = true; + rain.pages = {"What opens the inventory?\\n#L0#I#l#L1#K#l#L2#S#l", "That's right!!"}; + rain.questions[0].wrong[1] = "K is for skills"; + + QuestDialogue wrong(Mode::COMPLETION, rain, "#wrewards#w"); + require(wrong.page()->kind == QuestPage::SELECTION && !wrong.page()->previous, "a question has no Prev"); + require(wrong.respond(1, 1) == Effect::NONE, "a wrong answer pays nothing"); + require(wrong.page()->text == "K is for skills" && !wrong.page()->next && !wrong.page()->previous, + "a wrong answer explains why, with OK"); + require(wrong.respond(1) == Effect::NONE && wrong.finished(), "and ends the conversation"); + + QuestDialogue right(Mode::COMPLETION, rain, "#wrewards#w"); + require(right.respond(1, 2) == Effect::NONE, "an answer without an explanation is right"); + require(right.page()->text == "That's right!!#wrewards#w", "the last page carries the rewards"); + require(!right.page()->previous && !right.page()->next, "the last completion page has only OK"); + require(right.respond(1) == Effect::COMPLETE && right.finished() && right.read(), "OK completes the quest"); + + QuestDialogue closed(Mode::COMPLETION, rain); + closed.respond(1, 0); + require(closed.respond(-1) == Effect::NONE && closed.finished(), "closing the last page completes nothing"); + + ms::QuestScript unknown = rain; + unknown.questions.clear(); + QuestDialogue lost(Mode::COMPLETION, unknown); + require(lost.respond(1, 0) == Effect::NONE && lost.finished(), "a question without stop/ ends the conversation"); } -void completions_without_questions_complete_on_the_last_page() +void flipped_questions_say_their_lead_first() +{ + // Say.img/2270/1: Lana's questions. + ms::QuestScript lana; + lana.ask = true; + lana.pages = {"Are you here to be a star?\r\n#L0#No, for Blake.#l", "Blake?\r\n#L0#Give him a chance.#l", "I'll think."}; + lana.questions[0].flip = true; + lana.questions[1].flip = true; + + QuestDialogue offer(Mode::OFFER, lana); + QuestPage lead = *offer.page(); + require(lead.kind == QuestPage::TEXT && lead.text == "Are you here to be a star?\r\n", "the lead comes first"); + require(lead.next && !lead.previous, "the first lead has only Next"); + offer.respond(1); + require(offer.page()->kind == QuestPage::SELECTION && offer.page()->text == "\r\n#L0#No, for Blake.#l", + "then the choices on their own"); + offer.respond(1, 0); + require(offer.page()->text == "Blake?\r\n" && offer.page()->previous, "the next lead has Prev"); + require(offer.respond(0) == Effect::NONE && offer.finished(), "Prev from a lead ends the conversation"); + + QuestDialogue accept(Mode::OFFER, lana); + accept.respond(1); + accept.respond(1, 0); + accept.respond(1); + accept.respond(1, 0); + require(accept.page()->kind == QuestPage::YESNO, "an offer's last question page asks yes/no"); + require(accept.respond(1) == Effect::START, "Yes starts it"); +} + +void completions_page_to_the_rewards() { ms::QuestScript sera; - sera.pages = {"you talked to Heena?"}; - sera.yes = {"Maple Island is small", "use the portal"}; - QuestDialogue visit(Mode::COMPLETION, sera); - require(visit.page()->next, "more pages follow the greeting"); - require(visit.respond(1) == Effect::COMPLETE, "leaving the last page completes the visit"); - require(visit.page()->text == "Maple Island is small", "completion pages follow"); - require(visit.respond(0) == Effect::NONE && visit.page()->text == "Maple Island is small", - "Prev stays inside the branch"); - visit.respond(1); - visit.respond(1); - require(visit.finished(), "the dialogue ends after the completion pages"); - - ms::QuestScript sen; - QuestDialogue silent(Mode::COMPLETION, sen); - require(silent.take_initial_effect() == Effect::COMPLETE, "no completion text completes immediately"); - require(silent.finished(), "and shows nothing"); + sera.pages = {"you talked to Heena?", "here you go"}; + sera.yes = {"Maple Island is small"}; + QuestDialogue visit(Mode::COMPLETION, sera, "#w+exp#w"); + require(visit.page()->next && visit.page()->text == "you talked to Heena?", "pages before the last have Next"); + require(visit.respond(1) == Effect::NONE, "paging completes nothing"); + require(visit.page()->text == "here you go#w+exp#w" && !visit.page()->previous, "the last page has no Prev"); + require(visit.respond(1) == Effect::COMPLETE && visit.finished(), "OK completes the quest"); - QuestDialogue silent_offer(Mode::OFFER, ms::QuestScript{}); - require(silent_offer.take_initial_effect() == Effect::START, "no offer text starts immediately"); + QuestDialogue silent(Mode::COMPLETION, ms::QuestScript{}); + require(silent.take_initial_effect() == Effect::COMPLETE && silent.finished(), "nothing to say completes at once"); + + QuestDialogue rewards_only(Mode::COMPLETION, ms::QuestScript{}, "#w+exp#w"); + require(rewards_only.take_initial_effect() == Effect::NONE, "rewards alone are still shown"); + require(rewards_only.page()->text == "#w+exp#w", "on a page of their own"); } -void offers_ending_in_a_question_start_when_answered() +void rewards_are_listed_under_the_last_page() { - ms::QuestScript test; - test.pages = {"Are you brave?\\n#L0#Yes#l#L1#No#l"}; - test.answers[0] = 0; - QuestDialogue offer(Mode::OFFER, test); - require(offer.respond(1, 1) == Effect::NONE, "the wrong answer does not start it"); - require(offer.respond(1, 0) == Effect::START, "the right answer starts it"); + ms::QuestRewards rewards; + rewards.exp = 30; + rewards.items = { + { 2000000, 5 }, + { 4031802, -1 }, + { 1002053, 1, 1 << 1 }, + { 1041002, 1, -1, 1 }, + { 1002008, 1, -1, 2, 1 } + }; + + const std::string HEADER = "#w\r\n\r\n#fUI/UIWindow.img/QuestIcon/4/0#\r\n"; + const std::string EXP = "#fUI/UIWindow.img/QuestIcon/8/0# 30 exp\r\n\r\n"; + const std::string RANDOM = "#FUI/UIWindow.img/QuestIcon/5/0#\r\n"; + + require(rewards.summary(0, false, false) == HEADER + "#v2000000:# #t2000000:# 5\r\n\r\n" + EXP + RANDOM + "#w", + "items taken, or for other jobs or genders, are left out; random ones are not named"); + require(rewards.summary(100, true, false) == + HEADER + "#v2000000:# #t2000000:# 5\r\n#v1002053:# #t1002053:# 1\r\n#v1041002:# #t1041002:# 1\r\n\r\n" + EXP + + RANDOM + "#w", + "a warrior's hat and a woman's shirt"); + require(rewards.summary(900, false, false).find("1002053") != std::string::npos, "GMs pass every job filter"); + require(rewards.summary(0, false, true).empty(), "nothing is listed when another quest follows"); + require(ms::QuestRewards{}.summary(0, false, false).empty(), "or when there is nothing to list"); } -void reminders_only_page() +void pages_are_read_to_the_end() { - ms::QuestScript heena; - heena.reminder = {"Sera is east"}; - QuestDialogue reminder(Mode::REMINDER, heena); - require(reminder.page()->text == "Sera is east", "reminders show stop/npc"); - require(reminder.respond(1) == Effect::NONE && reminder.finished(), "reminders have no effect"); - QuestDialogue empty(Mode::REMINDER, ms::QuestScript{}); - require(empty.finished(), "no reminder text shows nothing"); + QuestDialogue yes = QuestDialogue::reading({"well done", "see Sera"}); + require(yes.page()->next && !yes.page()->previous, "the first page has Next"); + yes.respond(1); + require(yes.page()->previous && !yes.page()->next, "the last page has Prev and OK"); + require(yes.respond(1) == Effect::NONE && yes.finished() && yes.read(), "OK on the last page reads them all"); + + QuestDialogue closed = QuestDialogue::reading({"well done", "see Sera"}); + closed.respond(-1); + require(closed.finished() && !closed.read(), "closing early does not"); + + require(QuestDialogue::reading({}).finished(), "no pages show nothing"); + + ms::QuestScript script; + script.stops["default"] = {"not yet"}; + script.stops["item"] = {"bring the apples"}; + script.stops["mob"] = {}; + require(script.stop("item") == std::vector{"bring the apples"}, "stop/ first"); + require(script.stop("mob") == std::vector{"not yet"}, "then stop/default"); + require(ms::QuestScript{}.stop("npc").empty(), "or nothing"); } int main() @@ -117,10 +184,11 @@ int main() try { offers_page_then_ask_for_acceptance(); - quiz_answers_complete_once(); - completions_without_questions_complete_on_the_last_page(); - offers_ending_in_a_question_start_when_answered(); - reminders_only_page(); + wrong_answers_end_the_conversation(); + flipped_questions_say_their_lead_first(); + completions_page_to_the_rewards(); + rewards_are_listed_under_the_last_page(); + pages_are_read_to_the_end(); std::cout << "Quest dialogue passed\n"; return 0; } diff --git a/HeavenClient/tests/quest_marker.cpp b/HeavenClient/tests/quest_marker.cpp index 79629e147..e7e7ce9cc 100644 --- a/HeavenClient/tests/quest_marker.cpp +++ b/HeavenClient/tests/quest_marker.cpp @@ -105,6 +105,23 @@ int main() progress = "001"; require(hunt_marker() == QuestMarker::READY, "kills and item complete the hunt"); require(ms::decode_kills("003012") == std::vector({3, 12}), "progress decodes three digits per mob"); + // v83 checks the NPC, items, quests, then kills (FUN_00721d2c). + hunt.end_prerequisites = {{1034, 2}}; + int16_t before = 1; + auto stop = [&](int32_t npc) { + return ms::completion_stop(hunt, npc, [&](int16_t id) { return id == 1034 ? before : 1; }, + [&](int32_t) { return shells; }, progress); + }; + shells = 0; + progress = "000"; + require(stop(2004) == ms::QuestStop::NPC, "the wrong NPC is told first"); + require(stop(2002) == ms::QuestStop::ITEM, "then missing items"); + shells = 1; + require(stop(2002) == ms::QuestStop::QUEST, "then quests in the wrong state"); + before = 2; + require(stop(2002) == ms::QuestStop::MOB, "then kills"); + progress = "001"; + require(stop(2002) == ms::QuestStop::NONE, "and then it can be completed"); std::cout << "Quest marker transitions and priority passed\n"; return 0; } diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 2ba40d47b..7362b6e64 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -21,9 +21,10 @@ Quests run the way the original client ran them: atomically (`db::quest::start`/`complete`/`forfeit`). 4. It answers with the quest record (`SHOW_STATUS_INFO` mode 1), EXP and mesos with their stats if any (both shown in the chat log, as mode 3 with - its in-chat flag and mode 5), and `UPDATE_QUEST_INFO` mode 8 (quest, NPC, next quest). A - nonzero next quest (`Act/1/nextQuest`) makes the client continue straight - into that quest's offer once the current conversation ends. + its in-chat flag and mode 5), and `UPDATE_QUEST_INFO` mode 8 (quest, NPC, next quest). + Mode 8 is what makes the client show the step's `yes` pages; a nonzero + next quest (`Act/1/nextQuest`) opens once they have been read to the end + (straight away when there are none). The server sends no quest dialogue and keeps no quest conversation state. Rejected actions (unsupported quest, wrong NPC or state, unmet job, level, or @@ -116,20 +117,31 @@ Completing a quest also plays the v83 "Quest Clear" effect ## Conversations in the client `Character/QuestDialogue.h` is a pure state machine over one side of -`Say.img/`: - -- **Offer** (`/0`): numbered pages, the last one asking yes/no; `yes` pages - follow acceptance and `no` pages a decline. Accepting sends start. An offer - ending in a question starts when answered correctly. -- **Completion** (`/1`): numbered pages, then `yes` pages. Quiz pages contain - `#L` choices and `stop//answer` (one-based); a wrong choice shows - `stop//` and asks again. Passing the last question, or leaving - the last page of a talk-only completion, sends complete. There is no going - back to an answered question. -- **Reminder**: `stop/npc` pages when the start NPC is clicked while the - quest is open; at the finishing NPC, `stop/mob` while kills are missing and - `stop/item` while items are. -- No pages at all: the action is sent immediately (Sen's completion of 1032). +`Say.img/`, following v83's `FUN_00716fe1` (reports/182-11 and 182-11b in +the v83ref notes): + +- **Offer** (`/0`): numbered pages with Prev/Next, the last one asking + yes/no. Yes sends start and closes; No shows the `no` pages. No pages at + all sends start at once. +- **Completion** (`/1`): before anything is said, `Check/1` is checked in + v83's order (NPC, items, quests, kills; `completion_stop` in + `QuestMarker.h`). A failure shows `stop/`, else + `stop/default`, else the NPC's own `String/Npc.img//d0`, and sends + nothing. At the wrong NPC, `lost` replaces `stop/npc` when a quest item + given at the start (`Act/0/item`) is no longer held or worn. Otherwise + the pages play with Prev/Next; the last one has only OK, which sends + complete (closing it sends nothing). +- **Questions** (`ask` 1): every page but the last is a selection without + Prev. A choice with a `stop//` text is wrong: that text shows + with OK and the conversation ends, so the player talks to the NPC again. + Any other choice is right; `answer` means nothing to v83. With + `stop//flip`, the text before `#L0#` is a page of its own first. +- **Rewards**: the last completion page gets v83's list (`QuestRewards`, + `Character/QuestRewards.h`): "REWARD!!" with the fixed items for the + player's job and gender, meso, EXP, and the "?" box when one random item + is given, unless another quest follows. It sits between `#w` marks, which + `UINpcTalk` shows at once while the page above types out. +- **Yes pages** come only with the server's mode 8, as above. Markers use the same catalog (`QuestLog::supported_rules`) and v83's rules (`FUN_006d1779`). An NPC's quests are those it starts or finishes, less party From 9ecf19bd71434a19ef88442aec60701b4bdfc755 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 03:37:41 -0400 Subject: [PATCH 20/23] L182: v83 NPC quest menu groups and lines Clicking an NPC now groups its quests the way v83 does (FUN_006d1779): a started quest goes under "QUEST THAT CAN BE COMPLETED" only when its completion check passes with this NPC, and otherwise under "QUEST IN PROGRESS", so Pio's hand-in no longer looks ready before the items are in. The groups come in v83's order (completable, available, in progress, then ETC) and party quests 1200-1399 are left out. Each line follows FUN_00724bac: "#d#L# ", the QuestInfo type in bold red when there is one, the name, and " (Pre-completion enabled)" or " (In Progress)" for started quests. The text layout gains #d, v83's dialog font 10 colour #51378C, and a listed choice now starts in the colour in effect before its #L. Bold (#e) is still drawn as normal text. --- HeavenClient/Character/QuestCatalog.cpp | 1 + HeavenClient/Character/QuestInfo.h | 3 ++ HeavenClient/Character/QuestMarker.h | 57 +++++++++++++++++++++ HeavenClient/Gameplay/QuestConversation.cpp | 33 +++++------- HeavenClient/Graphics/Color.h | 4 +- HeavenClient/Graphics/TextLayout.cpp | 3 ++ HeavenClient/IO/UITypes/UINpcTalk.cpp | 3 ++ HeavenClient/Util/NpcTextMarkup.h | 33 ++++++++++++ HeavenClient/tests/npc_text.cpp | 4 ++ HeavenClient/tests/quest_marker.cpp | 28 ++++++++++ agent_docs/quests.md | 13 +++-- 11 files changed, 156 insertions(+), 26 deletions(-) diff --git a/HeavenClient/Character/QuestCatalog.cpp b/HeavenClient/Character/QuestCatalog.cpp index e6b430850..6f2ed31ff 100644 --- a/HeavenClient/Character/QuestCatalog.cpp +++ b/HeavenClient/Character/QuestCatalog.cpp @@ -43,6 +43,7 @@ namespace ms QuestInfo info; info.id = id; info.name = root["QuestInfo.img"][name]["name"].get_string(); + info.type = root["QuestInfo.img"][name]["type"].get_string(); info.start_npc = static_cast(start["npc"].get_integer()); // A hand-in quest without a completion NPC goes back to the NPC // that gave it (Pio's 1008), as on the server. diff --git a/HeavenClient/Character/QuestInfo.h b/HeavenClient/Character/QuestInfo.h index 2fd78d971..bc40a0fa7 100644 --- a/HeavenClient/Character/QuestInfo.h +++ b/HeavenClient/Character/QuestInfo.h @@ -35,5 +35,8 @@ namespace ms bool end_script = false; QuestRewards rewards; int16_t next_quest = 0; + // QuestInfo.img//type, such as "[Job Adv.]": shown before the + // name in an NPC's quest menu. + std::string type; }; } diff --git a/HeavenClient/Character/QuestMarker.h b/HeavenClient/Character/QuestMarker.h index aeba6b1ff..04ba96e8a 100644 --- a/HeavenClient/Character/QuestMarker.h +++ b/HeavenClient/Character/QuestMarker.h @@ -140,4 +140,61 @@ namespace ms return in_progress ? QuestMarker::IN_PROGRESS : QuestMarker::NONE; } + + // The quests an NPC lists when clicked, in v83's groups (FUN_006d1779): + // started quests it can complete now, quests it can start, and its other + // started quests. Party quests (1200-1399) are left out. + struct QuestMenu + { + std::vector completable; + std::vector available; + std::vector in_progress; + + bool empty() const + { + return completable.empty() && available.empty() && in_progress.empty(); + } + }; + + // `can_start` checks Check.img//0, `can_complete` Check.img//1 + // with this NPC. + template + QuestMenu select_quest_menu(int32_t npc_id, const std::vector& quests, Status status, + CanStart can_start, CanComplete can_complete) + { + QuestMenu menu; + + for (const auto& quest : quests) + { + if (quest.start_npc != npc_id && quest.end_npc != npc_id) + continue; + + if (quest.id >= 1200 && quest.id < 1400) + continue; + + int16_t state = status(quest.id); + + if (state == 1) + (can_complete(quest) ? menu.completable : menu.in_progress).push_back(quest.id); + else if (state == 0 && quest.start_npc == npc_id && can_start(quest)) + menu.available.push_back(quest.id); + } + + return menu; + } + + // A quest's line in that menu (FUN_00724bac): its type in bold red, its + // name, and for started quests whether it can be completed. + inline std::string quest_menu_line(int32_t choice, const QuestInfo& quest, bool started, bool completable) + { + std::string label = quest.name; + + if (started) + label += completable ? " (Pre-completion enabled)" : " (In Progress)"; + + if (!quest.type.empty()) + label = "#r#e" + quest.type + "#d#n" + label; + + return "#d#L" + std::to_string(choice) + "# " + label + "#l#k\r\n"; + } } diff --git a/HeavenClient/Gameplay/QuestConversation.cpp b/HeavenClient/Gameplay/QuestConversation.cpp index c5b62e57b..299368f2d 100644 --- a/HeavenClient/Gameplay/QuestConversation.cpp +++ b/HeavenClient/Gameplay/QuestConversation.cpp @@ -261,34 +261,25 @@ namespace ms void talk_to_npc(int32_t npc_id, int32_t object_id) { - // Same order as the original list: turn-ins, open quests, new quests. - std::vector turn_in, in_progress, available; + QuestMenu menu = select_quest_menu(npc_id, QuestCatalog::get().quests(), + [](int16_t id) { return state(id); }, can_start, + [npc_id](const QuestInfo& quest) { return completion_stop(quest, npc_id) == QuestStop::NONE; }); - for (const auto& quest : QuestCatalog::get().quests()) - { - int16_t current = state(quest.id); - - if (current == 1 && quest.end_npc == npc_id) - turn_in.push_back(quest.id); - else if (current == 1 && quest.start_npc == npc_id) - in_progress.push_back(quest.id); - else if (current == 0 && quest.start_npc == npc_id && can_start(quest)) - available.push_back(quest.id); - } - - if (turn_in.empty() && in_progress.empty() && available.empty()) + if (menu.empty()) { TalkToNPCPacket(object_id).dispatch(); return; } - // UtilDlgEx/list3 "QUEST THAT CAN BE COMPLETED", list0 "QUEST IN - // PROGRESS", list1 "QUEST AVAILABLE", list2 "ETC" (normal speech, - // worded as in period footage). + // In v83's order (FUN_006d3392), under UtilDlgEx/list3 "QUEST THAT + // CAN BE COMPLETED", list1 "QUEST AVAILABLE", list0 "QUEST IN + // PROGRESS" and list2 "ETC" (normal speech, worded as in period + // footage). std::string text; std::map headers; - for (auto [group, header] : { std::pair*, int>{ &turn_in, 3 }, { &in_progress, 0 }, { &available, 1 } }) + for (auto [group, header] : { std::pair*, int>{ &menu.completable, 3 }, + { &menu.available, 1 }, { &menu.in_progress, 0 } }) { for (size_t i = 0; i < group->size(); i++) { @@ -297,14 +288,14 @@ namespace ms if (i == 0) headers[id] = list_header(header); - text += " #L" + std::to_string(id) + "# " + QuestCatalog::get().find(id)->name + "#l"; + text += quest_menu_line(id, *QuestCatalog::get().find(id), header != 1, header == 3); } } if (has_speech(npc_id)) { headers[TALK_CHOICE] = list_header(2); - text += " #L" + std::to_string(TALK_CHOICE) + "# I have something to tell you.#l"; + text += "#d#L" + std::to_string(TALK_CHOICE) + "# I have something to tell you.#l#k\r\n"; } active.reset(); diff --git a/HeavenClient/Graphics/Color.h b/HeavenClient/Graphics/Color.h index 58585aff7..0d22d00d2 100644 --- a/HeavenClient/Graphics/Color.h +++ b/HeavenClient/Graphics/Color.h @@ -114,6 +114,7 @@ namespace ms PICTONBLUE, MAYABLUE, MAUVE, + SCRIPTPURPLE, NUM_COLORS }; @@ -177,7 +178,8 @@ namespace ms {0.75f, 0.24f, 0.01f}, // Strong mob (#BE3C03, v83 font 42: names of mobs 20+ levels above the player) {0.39f, 0.71f, 0.94f}, // Picton blue (#64B4F0, v83 font 5: quest messages in the status lines) {0.38f, 0.81f, 1.00f}, // Maya blue (#60CEFF, v83 chat font 10: server notices) - {0.88f, 0.67f, 1.00f} // Mauve (#E1ACFE, v83 chat font 4: guild chat) + {0.88f, 0.67f, 1.00f}, // Mauve (#E1ACFE, v83 chat font 4: guild chat) + {0.32f, 0.22f, 0.55f} // Script purple (#51378C, #d in v83 NPC dialog text) }; // Create a color by an array of real numbers [0.0f, 1.0f] diff --git a/HeavenClient/Graphics/TextLayout.cpp b/HeavenClient/Graphics/TextLayout.cpp index 6e3f1fe02..24c74660a 100644 --- a/HeavenClient/Graphics/TextLayout.cpp +++ b/HeavenClient/Graphics/TextLayout.cpp @@ -150,6 +150,9 @@ namespace ms case 'c': color = Color::Name::ORANGE; break; + case 'd': + color = Color::Name::SCRIPTPURPLE; + break; case 'w': // v83's "show at once" toggle (FUN_0099dd54); // the NPC dialog acts on it. diff --git a/HeavenClient/IO/UITypes/UINpcTalk.cpp b/HeavenClient/IO/UITypes/UINpcTalk.cpp index d732ee64c..07cf9102d 100644 --- a/HeavenClient/IO/UITypes/UINpcTalk.cpp +++ b/HeavenClient/IO/UITypes/UINpcTalk.cpp @@ -687,10 +687,13 @@ namespace ms while (link != std::string::npos && indent < link && formatted_text[link - indent - 1] == ' ') indent++; + // A label starts in the colour before it. + std::string colour = NpcTextMarkup::colour_at(formatted_text, link); link = formatted_text.find("#L", link + 2); // Listed choices are drawn without their images for now. label.erase(std::remove(label.begin(), label.end(), Text::BOX), label.end()); + label = colour + label; auto header = headers.find(id); Choice choice { id, Point(static_cast(indent * space), 0), {}, Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::DUSKBLUE, label, TEXT_WIDTH - LABEL_X) }; diff --git a/HeavenClient/Util/NpcTextMarkup.h b/HeavenClient/Util/NpcTextMarkup.h index 10c3d4bf2..2886ec94f 100644 --- a/HeavenClient/Util/NpcTextMarkup.h +++ b/HeavenClient/Util/NpcTextMarkup.h @@ -178,6 +178,39 @@ namespace ms return result; } + // The colour code (#k, #r, #g, #b or #d) in effect at `pos`, or + // nothing, as for "#d" before each line of an NPC's quest menu. + inline std::string colour_at(const std::string& text, size_t pos) + { + std::string colour; + + for (size_t i = 0; i + 1 < std::min(pos, text.size()); i++) + { + if (text[i] != '#') + continue; + + char tag = text[i + 1]; + + if (tag == 'L') + { + // Skip a choice's id: "#L1#bananas" is not blue. + i = text.find('#', i + 2); + + if (i == std::string::npos) + break; + + continue; + } + + if (std::string("krgbd").find(tag) != std::string::npos) + colour = text.substr(i, 2); + + i++; + } + + return colour; + } + // Where a choice starts and where its label ends, in text marked by // mark_choices. constexpr char CHOICE = '\x02'; diff --git a/HeavenClient/tests/npc_text.cpp b/HeavenClient/tests/npc_text.cpp index b5dff5d0b..fea8c10e3 100644 --- a/HeavenClient/tests/npc_text.cpp +++ b/HeavenClient/tests/npc_text.cpp @@ -47,6 +47,10 @@ int main() "unclosed choices end at the next #L or the end"); require(choices("no menu here").empty(), "text without choices"); require(choices("#Lx# broken").empty(), "a non-numeric id is not a choice"); + std::string menu = "#d#L1# Pio#l#k\r\n#d#L2# #r#e[Job Adv.]#d#nGrendel#l#k\r\n"; + require(ms::NpcTextMarkup::colour_at(menu, menu.find("#L1")) == "#d", "a choice takes the colour before it"); + require(ms::NpcTextMarkup::colour_at(menu, menu.find("#L2")) == "#d", "after the last one's #k"); + require(ms::NpcTextMarkup::colour_at("#L1#bananas#l#L2#", 12) == "", "a choice's id is not a colour"); auto mark = [](const std::string& s, std::vector expected) { std::vector ids; diff --git a/HeavenClient/tests/quest_marker.cpp b/HeavenClient/tests/quest_marker.cpp index e7e7ce9cc..d0d839d40 100644 --- a/HeavenClient/tests/quest_marker.cpp +++ b/HeavenClient/tests/quest_marker.cpp @@ -122,6 +122,34 @@ int main() require(stop(2002) == ms::QuestStop::MOB, "then kills"); progress = "001"; require(stop(2002) == ms::QuestStop::NONE, "and then it can be completed"); + // Pio (10000) lists a hand-in he can't take yet as in progress. + std::vector pio { quest(1008, 10000, 10000), quest(1020, 10000, 10000), + quest(1021, 999, 10000), quest(1022, 10000, 999), quest(1023, 10000, 10000), quest(1250, 10000, 10000) }; + std::map pio_states { {1008, 1}, {1021, 1}, {1022, 1}, {1023, 2}, {1250, 1} }; + bool items = false; + auto menu = [&]() { + return ms::select_quest_menu(10000, pio, [&](int16_t id) { return pio_states[id]; }, + [&](const ms::QuestInfo& info) { return ms::quest_available(info, 0, 1, [&](int16_t id) { return pio_states[id]; }); }, + [&](const ms::QuestInfo& info) { return info.end_npc == 10000 && (info.id != 1008 || items); }); + }; + ms::QuestMenu listed = menu(); + require(listed.completable == std::vector({1021}), "a started quest he can take is completable"); + require(listed.available == std::vector({1020}), "an unstarted quest he gives is available"); + require(listed.in_progress == std::vector({1008, 1022}), "the rest of his started quests are in progress"); + items = true; + require(menu().completable == std::vector({1008, 1021}), "the hand-in moves up once the items are in"); + pio_states = {}; + pio.resize(1); + require(!menu().empty() && ms::select_quest_menu(10000, {}, [](int16_t) { return 0; }, + [](const ms::QuestInfo&) { return true; }, [](const ms::QuestInfo&) { return true; }).empty(), "no quests, no menu"); + ms::QuestInfo job = quest(1400, 1012100, 1012100); + job.name = "The Path of a Bowman"; + require(ms::quest_menu_line(3, job, false, false) == "#d#L3# The Path of a Bowman#l#k\r\n", "a line names the quest"); + job.type = "[Job Adv.]"; + require(ms::quest_menu_line(3, job, true, false) == + "#d#L3# #r#e[Job Adv.]#d#nThe Path of a Bowman (In Progress)#l#k\r\n", "after its type, in red"); + require(ms::quest_menu_line(3, job, true, true).find(" (Pre-completion enabled)#l") != std::string::npos, + "and says when it can be completed"); std::cout << "Quest marker transitions and priority passed\n"; return 0; } diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 7362b6e64..ca5f82cfd 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -5,10 +5,15 @@ Quests run the way the original client ran them: 1. **HeavenClient** reads `Quest.nx` itself. Clicking an NPC lists its quests - in the NPC chat window under the v83 `UtilDlgEx/list*` headers ("QUEST - THAT CAN BE COMPLETED", "QUEST IN PROGRESS", "QUEST AVAILABLE"), with - "ETC: I have something to tell you." for the NPC's normal speech, as in - period footage. Picking a quest plays its `Say.img` pages locally + in the NPC chat window, grouped as v83 groups them (`select_quest_menu`): + "QUEST THAT CAN BE COMPLETED" (started, and `Check.img//1` passes + with this NPC), "QUEST AVAILABLE", then "QUEST IN PROGRESS" (its other + started quests, such as a hand-in still missing its items), under the + `UtilDlgEx/list*` headers. Each line is v83's `quest_menu_line`: the + QuestInfo `type` in red, the name, and "(Pre-completion enabled)" or + "(In Progress)". "ETC: I have something to tell you." stands for the + NPC's normal speech, as in period footage. Party quests (1200-1399) are + never listed. Picking a quest plays its `Say.img` pages locally (`Gameplay/QuestConversation.cpp`); an offer's yes/no page shows ACCEPT and DECLINE. 2. When the player accepts or finishes, the client sends `QUEST_ACTION` From a954b3c2f260bd4dad2b8c5a1e6b199dc85e6299 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 03:53:23 -0400 Subject: [PATCH 21/23] L182: talk to NPCs as v83 decides Clicking an NPC now follows v83's decision (FUN_0095fe9e, FUN_006d3392). An NPC without quests goes to the server. When there is a quest to list, the menu opens with the NPC's d0 greeting, and its ETC line is the NPC's script, named by Etc/ScriptInfo.img, rather than a fixed "I have something to tell you" for any NPC with speech. With nothing to list, an NPC with a script goes to the server, and one without says d1 if all of its quests are done for good, or d0, without asking the server. --- HeavenClient/Character/QuestMarker.h | 8 +++ HeavenClient/Gameplay/QuestConversation.cpp | 80 +++++++++++++++++---- HeavenClient/tests/quest_marker.cpp | 1 + agent_docs/npc-dialogue.md | 5 +- agent_docs/quests.md | 16 +++-- 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/HeavenClient/Character/QuestMarker.h b/HeavenClient/Character/QuestMarker.h index 04ba96e8a..58fabb96e 100644 --- a/HeavenClient/Character/QuestMarker.h +++ b/HeavenClient/Character/QuestMarker.h @@ -149,6 +149,10 @@ namespace ms std::vector completable; std::vector available; std::vector in_progress; + // All of the NPC's quests, and those completed for good. Party + // quests count only in the first. + size_t quests = 0; + size_t finished = 0; bool empty() const { @@ -169,6 +173,8 @@ namespace ms if (quest.start_npc != npc_id && quest.end_npc != npc_id) continue; + menu.quests++; + if (quest.id >= 1200 && quest.id < 1400) continue; @@ -178,6 +184,8 @@ namespace ms (can_complete(quest) ? menu.completable : menu.in_progress).push_back(quest.id); else if (state == 0 && quest.start_npc == npc_id && can_start(quest)) menu.available.push_back(quest.id); + else if (state == 2) + menu.finished++; } return menu; diff --git a/HeavenClient/Gameplay/QuestConversation.cpp b/HeavenClient/Gameplay/QuestConversation.cpp index 299368f2d..1abf1e460 100644 --- a/HeavenClient/Gameplay/QuestConversation.cpp +++ b/HeavenClient/Gameplay/QuestConversation.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -38,7 +39,7 @@ namespace ms std::optional active; - // Menu choice for the NPC's normal speech; quest ids are positive. + // Menu choice for the NPC's script; quest ids are positive. constexpr int32_t TALK_CHOICE = 0; Player& player() @@ -105,10 +106,34 @@ namespace ms return pages; } - // Whether the server has normal speech for this NPC (info/speak). - bool has_speech(int32_t npc_id) + // The NPC's script (Npc/.img/info/script//script): the + // last whose start and end dates include today (FUN_006de2d3). + // The server runs it when the NPC is talked to. + std::string npc_script(int32_t npc_id) { - return NxHelper::Npc::get_npc_node(npc_id)["info"]["speak"].size() > 0; + std::time_t now = std::time(nullptr); + std::tm local = *std::localtime(&now); + int64_t today = ((local.tm_year + 1900) * 100 + local.tm_mon + 1) * 100 + local.tm_mday; + nl::node scripts = nl::nx::npc[string_format::extend_id(npc_id, 7) + ".img"]["info"]["script"]; + std::string script; + + for (int n = 0; nl::node entry = scripts[std::to_string(n)]; n++) + { + std::string name = entry["script"]; + + if (!name.empty() && entry["start"].get_integer(19000101) <= today && + today <= entry["end"].get_integer(20790101)) + script = name; + } + + return script; + } + + // The NPC's own line, from String/Npc.img: d0 greets, d1 is for + // when all of its quests are done. + std::string npc_line(int32_t npc_id, const char* line) + { + return nl::nx::string["Npc.img"][std::to_string(npc_id)][line]; } Texture list_header(int index) @@ -142,6 +167,14 @@ namespace ms show(); } + // A page the NPC says without the server, as v83 does when + // there is nothing to list (FUN_006d3392). OK and END CHAT + // both just close it. + void say(int32_t npc_id, const std::string& text) + { + begin({ QuestDialogue::reading({ text }), 0, npc_id }); + } + void show() { auto page = active ? active->dialogue.page() : std::nullopt; @@ -265,17 +298,38 @@ namespace ms [](int16_t id) { return state(id); }, can_start, [npc_id](const QuestInfo& quest) { return completion_stop(quest, npc_id) == QuestStop::NONE; }); - if (menu.empty()) + // As v83 decides (FUN_0095fe9e, FUN_006d3392): the server runs an + // NPC without quests, or one with a script and nothing to list; + // otherwise the NPC says its own line here. + std::string script = npc_script(npc_id); + + if (menu.quests == 0 || (menu.empty() && !script.empty())) { TalkToNPCPacket(object_id).dispatch(); return; } - // In v83's order (FUN_006d3392), under UtilDlgEx/list3 "QUEST THAT - // CAN BE COMPLETED", list1 "QUEST AVAILABLE", list0 "QUEST IN - // PROGRESS" and list2 "ETC" (normal speech, worded as in period - // footage). - std::string text; + if (menu.quests == menu.finished) + { + say(npc_id, npc_line(npc_id, "d1")); + return; + } + + std::string greeting = npc_line(npc_id, "d0"); + + if (menu.empty()) + { + if (!greeting.empty()) + say(npc_id, greeting); + + return; + } + + // The greeting, then the quests in v83's order, under + // UtilDlgEx/list3 "QUEST THAT CAN BE COMPLETED", list1 "QUEST + // AVAILABLE" and list0 "QUEST IN PROGRESS", then the script under + // list2 "ETC", named by Etc/ScriptInfo.img or else by itself. + std::string text = greeting; std::map headers; for (auto [group, header] : { std::pair*, int>{ &menu.completable, 3 }, @@ -292,10 +346,12 @@ namespace ms } } - if (has_speech(npc_id)) + if (!script.empty()) { + std::string name = nl::nx::etc["ScriptInfo.img"][script].get_string(script); + headers[TALK_CHOICE] = list_header(2); - text += "#d#L" + std::to_string(TALK_CHOICE) + "# I have something to tell you.#l#k\r\n"; + text += "#d#L" + std::to_string(TALK_CHOICE) + "# " + name + "#l#k\r\n"; } active.reset(); diff --git a/HeavenClient/tests/quest_marker.cpp b/HeavenClient/tests/quest_marker.cpp index d0d839d40..b488c71b6 100644 --- a/HeavenClient/tests/quest_marker.cpp +++ b/HeavenClient/tests/quest_marker.cpp @@ -136,6 +136,7 @@ int main() require(listed.completable == std::vector({1021}), "a started quest he can take is completable"); require(listed.available == std::vector({1020}), "an unstarted quest he gives is available"); require(listed.in_progress == std::vector({1008, 1022}), "the rest of his started quests are in progress"); + require(listed.quests == 6 && listed.finished == 1, "all his quests count, the party quest too, and one is done"); items = true; require(menu().completable == std::vector({1008, 1021}), "the hand-in moves up once the items are in"); pio_states = {}; diff --git a/agent_docs/npc-dialogue.md b/agent_docs/npc-dialogue.md index e71f88c20..66dcdc8f0 100644 --- a/agent_docs/npc-dialogue.md +++ b/agent_docs/npc-dialogue.md @@ -3,8 +3,9 @@ Clicking an NPC opens asset-backed dialogue. Pio and other NPCs with normal speech use Next, Back, and OK. Close and Escape cancel. When the NPC has quests for the player, HeavenClient shows its quest menu first and renders -quest conversations itself; picking "Misc." asks the server for normal speech. -See [Quests](./quests.md). +quest conversations itself; picking the "ETC" line (the NPC's script) asks +the server to talk. See [Quests](./quests.md) for when the client talks +instead of the server. The NPC chat key (Space by default, action 55) talks to the nearest NPC within 150 px horizontally and 50 px vertically of the player's feet diff --git a/agent_docs/quests.md b/agent_docs/quests.md index ca5f82cfd..983eef826 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -4,16 +4,22 @@ Quests run the way the original client ran them: -1. **HeavenClient** reads `Quest.nx` itself. Clicking an NPC lists its quests - in the NPC chat window, grouped as v83 groups them (`select_quest_menu`): +1. **HeavenClient** reads `Quest.nx` itself. Clicking an NPC that has quests + lists them in the NPC chat window under its `String/Npc.img` `d0` + greeting, grouped as v83 groups them (`select_quest_menu`): "QUEST THAT CAN BE COMPLETED" (started, and `Check.img//1` passes with this NPC), "QUEST AVAILABLE", then "QUEST IN PROGRESS" (its other started quests, such as a hand-in still missing its items), under the `UtilDlgEx/list*` headers. Each line is v83's `quest_menu_line`: the QuestInfo `type` in red, the name, and "(Pre-completion enabled)" or - "(In Progress)". "ETC: I have something to tell you." stands for the - NPC's normal speech, as in period footage. Party quests (1200-1399) are - never listed. Picking a quest plays its `Say.img` pages locally + "(In Progress)". If the NPC has a script (`Npc/.img/info/script`, + the last entry dated for today), an "ETC" line named by + `Etc/ScriptInfo.img/