diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 8a4612bba..645631dd1 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -46,6 +46,10 @@ 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(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) @@ -93,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) @@ -191,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) @@ -208,6 +217,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(.) @@ -313,6 +324,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 @@ -468,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 @@ -523,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 @@ -567,8 +582,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 @@ -602,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 @@ -710,6 +727,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/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/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 67712fa5e..aee6e3ccb 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" @@ -25,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" @@ -205,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) @@ -607,7 +611,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/QuestCatalog.cpp b/HeavenClient/Character/QuestCatalog.cpp index 93bbe6ac7..6f2ed31ff 100644 --- a/HeavenClient/Character/QuestCatalog.cpp +++ b/HeavenClient/Character/QuestCatalog.cpp @@ -43,13 +43,15 @@ 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. 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 +68,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 +137,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..49bd4800d 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 @@ -39,29 +62,52 @@ namespace ms std::string text; bool previous; bool next; + // The dialog's speaker byte: 2 has the player answer a flipped + // question (FUN_00716fe1). + int8_t speaker = 0; }; // 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 +121,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()) + return QuestPage { QuestPage::SELECTION, text, false, false }; - 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 }; + return QuestPage { QuestPage::SELECTION, "\r\n" + text.substr(text.find(CHOICES)), false, false, 2 }; + } + 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..bc40a0fa7 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,19 @@ 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; + // 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 7fdf48934..58fabb96e 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) @@ -102,4 +140,69 @@ 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; + // 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 + { + 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; + + menu.quests++; + + 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); + else if (state == 2) + menu.finished++; + } + + 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/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/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/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..236d07858 100644 --- a/HeavenClient/Data/SkillData.cpp +++ b/HeavenClient/Data/SkillData.cpp @@ -71,13 +71,18 @@ 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.x = sub["x"]; + added.mesocost = sub["moneyCon"]; + added.itemcost = sub["itemCon"]; + added.itemcount = sub["itemConNo"]; } element = std::string(src["elemAttr"]); @@ -89,10 +94,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..43e97d056 100644 --- a/HeavenClient/Data/SkillData.h +++ b/HeavenClient/Data/SkillData.h @@ -60,6 +60,12 @@ 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; + 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/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/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/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 bcaeb8806..014dd0d15 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" @@ -244,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; @@ -401,12 +411,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/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/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/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/Gameplay/QuestConversation.cpp b/HeavenClient/Gameplay/QuestConversation.cpp index 3c06ce4e9..baa8dc0c9 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" @@ -15,6 +16,7 @@ #include #include +#include #include #include #include @@ -31,13 +33,13 @@ 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. + // Menu choice for the NPC's script; quest ids are positive. constexpr int32_t TALK_CHOICE = 0; Player& player() @@ -58,25 +60,80 @@ 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 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; + } + + // 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) + { + 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 end_mobs_met(quest, iter == started.end() ? std::string() : iter->second); + return script; } - // Whether the server has normal speech for this NPC (info/speak). - bool has_speech(int32_t npc_id) + // 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 NxHelper::Npc::get_npc_node(npc_id)["info"]["speak"].size() > 0; + return nl::nx::string["Npc.img"][std::to_string(npc_id)][line]; } Texture list_header(int index) @@ -103,18 +160,37 @@ namespace ms show(); } + void begin(Active conversation) + { + active = std::move(conversation); + send(active->dialogue.take_initial_effect(), active->quest_id, active->npc_id); + 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; 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; @@ -142,7 +218,7 @@ namespace ms if (auto talk = UI::get().get_element()) { - talk->change_text(active->npc_id, type, style, 0, page->text, {}, UINpcTalk::Menu::LIST); + talk->change_text(active->npc_id, type, style, page->speaker, page->text, {}, UINpcTalk::Menu::LIST); talk->set_local_responder(respond); } } @@ -155,86 +231,109 @@ namespace ms if (!quest) return; - std::optional mode; + if (state(quest_id) == 0) + { + if (quest->start_npc != npc_id || !can_start(*quest)) + return; + + 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 (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; + begin({ QuestDialogue(QuestDialogue::Mode::OFFER, QuestCatalog::get().script(quest_id, false)), quest_id, npc_id }); + return; + } - if (!mode) + if (state(quest_id) != 1) return; - bool completion_side = *mode != QuestDialogue::Mode::OFFER; - QuestScript script = QuestCatalog::get().script(quest_id, completion_side); + // 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 (*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)) + 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) { - // 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; }); + + // 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; + } - for (const auto& quest : QuestCatalog::get().quests()) + if (menu.quests == menu.finished) { - 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); + say(npc_id, npc_line(npc_id, "d1")); + return; } - if (turn_in.empty() && in_progress.empty() && available.empty()) + std::string greeting = npc_line(npc_id, "d0"); + + if (menu.empty()) { - TalkToNPCPacket(object_id).dispatch(); + if (!greeting.empty()) + say(npc_id, greeting); + return; } - // UtilDlgEx/list3 "QUEST THAT CAN BE COMPLETED", list0 "QUEST IN - // PROGRESS", list1 "QUEST AVAILABLE", list2 "ETC" (normal speech, - // worded as in period footage). - std::string text; + // 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>{ &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++) { @@ -243,18 +342,19 @@ 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)) + if (!script.empty()) { + std::string name = nl::nx::etc["ScriptInfo.img"][script].get_string(script); + 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) + "# " + name + "#l#k\r\n"; } 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/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index f8abbb7d1..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) @@ -834,6 +854,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..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. @@ -129,6 +132,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/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/GraphicsGL.cpp b/HeavenClient/Graphics/GraphicsGL.cpp index c9a56b3e6..c33be978c 100644 --- a/HeavenClient/Graphics/GraphicsGL.cpp +++ b/HeavenClient/Graphics/GraphicsGL.cpp @@ -635,15 +635,36 @@ namespace ms quads.emplace_back(rect.left(), rect.right(), rect.top(), rect.bottom(), offset, color, angle); } + namespace + { + // The font #e switches to; v83 picks a dialog font by colour and + // boldness. Fonts without a bold keep their own. + Text::Font bold_font(Text::Font id) + { + switch (id) + { + case Text::Font::A11M: + return Text::Font::A11B; + case Text::Font::A12M: + return Text::Font::A12B; + case Text::Font::A13M: + return Text::Font::A13B; + default: + return id; + } + } + } + Text::Layout GraphicsGL::createlayout(const std::string &text, Text::Font id, Text::Alignment alignment, int16_t maxwidth, bool formatted, int16_t line_adj, const std::vector> &boxes) { const Font &font = fonts[id]; + const Font &bold = fonts[bold_font(id)]; - return layout_text(text, font.linespace(), [&font](char32_t c) + return layout_text(text, font.linespace(), [&font, &bold](char32_t c, bool is_bold) { - return font.glyph(c).ax; + return (is_bold ? bold : font).glyph(c).ax; }, alignment, maxwidth, formatted, line_adj, boxes); } @@ -706,6 +727,7 @@ namespace ms wordcolor = Color::colors[colorid]; Color abscolor = color * Color(wordcolor[0], wordcolor[1], wordcolor[2], 1.0f); + const Font &wordfont = fonts[word.bold ? bold_font(id) : id]; for (size_t pos = word.first; pos < word.last; ++pos) { @@ -728,7 +750,7 @@ namespace ms if (Utf8::decode(text, pos, c) == 0) continue; - const Font::Char &ch = font.glyph(c); + const Font::Char &ch = wordfont.glyph(c); // The layout already places every character, including giving a // wrapped line's leading space no width, so use its advances diff --git a/HeavenClient/Graphics/Text.h b/HeavenClient/Graphics/Text.h index c06748694..6785e1f20 100644 --- a/HeavenClient/Graphics/Text.h +++ b/HeavenClient/Graphics/Text.h @@ -76,6 +76,8 @@ namespace ms size_t last; Font font; Color::Name color; + // Between #e and #n: the bold of the text's font. + bool bold = false; }; struct Line diff --git a/HeavenClient/Graphics/TextLayout.cpp b/HeavenClient/Graphics/TextLayout.cpp index 4d25424cc..3f1c86f9e 100644 --- a/HeavenClient/Graphics/TextLayout.cpp +++ b/HeavenClient/Graphics/TextLayout.cpp @@ -27,7 +27,7 @@ namespace ms { public: LayoutBuilder(const std::string &text, int16_t linespace, - const std::function &advance, Text::Alignment alignment, + const std::function &advance, Text::Alignment alignment, int16_t maxwidth, bool formatted, int16_t line_adj, const std::vector> &boxes); @@ -36,7 +36,7 @@ namespace ms Text::Layout finish(size_t first, size_t last); private: - void add_word(size_t first, size_t last, Text::Font font, Color::Name color); + void add_word(size_t first, size_t last, Text::Font font, Color::Name color, bool bold); void add_line(); @@ -46,11 +46,12 @@ namespace ms const std::string &text; int16_t linespace; - const std::function &advance; + const std::function &advance; Text::Alignment alignment; Text::Font fontid; Color::Name color; + bool bold; int16_t maxwidth; bool formatted; @@ -72,7 +73,7 @@ namespace ms int16_t line_top; }; - LayoutBuilder::LayoutBuilder(const std::string &t, int16_t ls, const std::function &adv, + LayoutBuilder::LayoutBuilder(const std::string &t, int16_t ls, const std::function &adv, Text::Alignment a, int16_t mw, bool fm, int16_t la, const std::vector> &b) : text(t), linespace(ls), advance(adv), alignment(a), maxwidth(mw), formatted(fm), @@ -80,6 +81,7 @@ namespace ms { fontid = Text::Font::NUM_FONTS; color = Color::Name::NUM_COLORS; + bold = false; ax = 0; ay = linespace; width = 0; @@ -103,6 +105,7 @@ namespace ms Text::Font last_font = fontid; Color::Name last_color = color; + bool last_bold = bold; size_t skip = 0; bool linebreak = false; @@ -150,8 +153,18 @@ namespace ms case 'c': color = Color::Name::ORANGE; break; + case 'd': + color = Color::Name::SCRIPTPURPLE; + break; + case 'e': + bold = true; + break; + case 'n': + bold = false; + break; case 'w': - color = Color::Name::WHITE; + // v83's "show at once" toggle (FUN_0099dd54); + // the NPC dialog acts on it. break; } @@ -194,7 +207,7 @@ namespace ms bool newline = linebreak || ax > 0 && ax + wordwidth > maxwidth; if (newword || newline) - add_word(prev, first, last_font, last_color); + add_word(prev, first, last_font, last_color, last_bold); if (newline) { @@ -242,7 +255,7 @@ namespace ms Text::Layout LayoutBuilder::finish(size_t first, size_t last) { - add_word(first, last, fontid, color); + add_word(first, last, fontid, color, bold); add_line(); advances.push_back(ax); @@ -270,13 +283,13 @@ namespace ms if (Utf8::decode(text, pos, codepoint) == 0) return 0; - return advance(codepoint); + return advance(codepoint, bold); } void LayoutBuilder::add_word(size_t word_first, size_t word_last, Text::Font word_font, - Color::Name word_color) + Color::Name word_color, bool word_bold) { - words.push_back({word_first, word_last, word_font, word_color}); + words.push_back({word_first, word_last, word_font, word_color, word_bold}); } void LayoutBuilder::add_line() @@ -304,7 +317,7 @@ namespace ms } Text::Layout layout_text(const std::string &text, int16_t linespace, - const std::function &advance, Text::Alignment alignment, + const std::function &advance, Text::Alignment alignment, int16_t maxwidth, bool formatted, int16_t line_adj, const std::vector> &boxes) { diff --git a/HeavenClient/Graphics/TextLayout.h b/HeavenClient/Graphics/TextLayout.h index 0d3a4a10e..38cf45311 100644 --- a/HeavenClient/Graphics/TextLayout.h +++ b/HeavenClient/Graphics/TextLayout.h @@ -24,11 +24,11 @@ namespace ms { // Lay out a text in a font whose lines are `linespace` apart and whose - // characters move the pen by `advance`. The n-th Text::BOX character takes + // characters move the pen by `advance`, given whether they are bold. The n-th Text::BOX character takes // the n-th of `boxes` (width, height). Positions in the layout are byte // offsets into `text` (UTF-8). Text::Layout layout_text(const std::string &text, int16_t linespace, - const std::function &advance, Text::Alignment alignment, + const std::function &advance, Text::Alignment alignment, int16_t maxwidth, bool formatted, int16_t line_adj, const std::vector> &boxes); } 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/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/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/Messages.cpp b/HeavenClient/IO/Messages.cpp index 8f6f33bfa..f5eb09fdf 100644 --- a/HeavenClient/IO/Messages.cpp +++ b/HeavenClient/IO/Messages.cpp @@ -21,41 +21,52 @@ #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." + "Since you are not using a weapon, you are unable to attack.", + "This item cannot be used here.", + "You cannot drop the item here." }; - 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 +75,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..9e94b312c 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) @@ -42,27 +50,36 @@ namespace ms DOOR_PORTAL, // Attacking without a weapon ATTACK_NOWEAPON, + // The map forbids the item (fieldLimit) + ITEM_FIELD, + DROP_FIELD, LENGTH }; 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/UIElement.h b/HeavenClient/IO/UIElement.h index 489de0141..a4d5e2409 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -80,7 +80,8 @@ namespace ms MONSTERBOOK, REVIVE, TRADINGROOM, - TRADEINVITE, + INVITECARD, + CONTEXTMENU, NUM_TYPES }; diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index a45d4c755..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" @@ -41,7 +42,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 +105,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(); @@ -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) @@ -611,7 +622,8 @@ namespace ms UIElement::Type::NPCTALK, UIElement::Type::SHOP, UIElement::Type::STORAGE, - UIElement::Type::TRADEINVITE, + UIElement::Type::INVITECARD, + UIElement::Type::CONTEXTMENU, UIElement::Type::REVIVE }; 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/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/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index ed133b370..66fcd2993 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(); @@ -429,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; @@ -670,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/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/UIInviteCard.cpp b/HeavenClient/IO/UITypes/UIInviteCard.cpp new file mode 100644 index 000000000..1d27dd869 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIInviteCard.cpp @@ -0,0 +1,231 @@ +#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 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, ARRIVAL_LIFETIME = 6000; + + struct Look + { + const char* icon; + const char* heading; + // v83's text position (FUN_0052065d), moved up to HC's text tops. + Point text; + }; + + // 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)) + { + nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; + + background = fade["backgrnd"]; + arrival_background = fade["backgrnd2"]; + + 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 = notice.icon; + + (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)); + 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. + 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) + { + 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()); + + notices.push_back(std::move(notice)); + 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()); + + 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 + { + 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; + + 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(invitations ? WIDTH : ARRIVAL_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; + + if (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..d71088c63 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIInviteCard.h @@ -0,0 +1,85 @@ +#pragma once + +#include "../UIElement.h" +#include "../Components/FadeNotice.h" + +#include "../../Graphics/Text.h" +#include "../../Graphics/Texture.h" + +#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. 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: + static constexpr Type TYPE = UIElement::Type::INVITECARD; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + // v83's notice types 0, 1, 2, 5 and 9. + enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, ARRIVAL, 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); + // 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; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + enum Buttons : uint16_t { OK, CANCEL }; + + struct Notice + { + Kind kind; + 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, 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. + 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, ARRIVAL_WIDTH = 155, HEIGHT = 44; + }; +} diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 92c0fbfcf..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) @@ -294,7 +309,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; @@ -304,10 +319,15 @@ 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("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; @@ -327,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: @@ -335,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; @@ -587,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; } @@ -796,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) @@ -810,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); } }; @@ -856,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/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/UINpcTalk.cpp b/HeavenClient/IO/UITypes/UINpcTalk.cpp index 623b572f4..908ca65cc 100644 --- a/HeavenClient/IO/UITypes/UINpcTalk.cpp +++ b/HeavenClient/IO/UITypes/UINpcTalk.cpp @@ -46,6 +46,15 @@ namespace ms // fit the text plus 34 px, and the window is centred on the screen. constexpr int16_t TOP = 28; constexpr int16_t TEXT_X = 165; + // The speaker's centre. With the speaker on the right (v83 9a7736) + // the text, NEXT and BACK move left (9accd2, 9a7d5e); the scroll bar + // moves with the right-side art's text panel, as v83's own place for + // it is unknown. + constexpr int16_t SPEAKER_X = 80; + constexpr int16_t RIGHT_SPEAKER_X = 450; + constexpr int16_t RIGHT_TEXT_SHIFT = 132; + constexpr int16_t RIGHT_BUTTON_SHIFT = 130; + constexpr int16_t RIGHT_SLIDER_SHIFT = 131; constexpr int16_t TEXT_WIDTH = 312; constexpr int16_t LINE = 18; constexpr int16_t PADDING = 34; @@ -123,9 +132,6 @@ namespace ms { nl::node UtilDlgEx = NxFiles::ui_classic["UIWindow.img"]["UtilDlgEx"]; - top = UtilDlgEx["t"]; - fill = UtilDlgEx["c"]; - bottom = UtilDlgEx["s"]; nametag = UtilDlgEx["bar"]; dot_selected = UtilDlgEx["dot1"]; dot = UtilDlgEx["dot0"]; @@ -166,10 +172,14 @@ namespace ms UIElement::draw(inter); int16_t total = TOP + height + bottom.height(); - Point speaker_pos = position + Point(20, total / 2 + 11); - Point center_pos = speaker_pos + Point(nametag.width() / 2, 0); + Point center_pos = position + Point(right_side ? RIGHT_SPEAKER_X : SPEAKER_X, total / 2 + 11); + Point speaker_pos = center_pos - Point(nametag.width() / 2, 0); + + if (player_speaks) + Stage::get().get_player().get_look().draw(center_pos, false, Stance::Id::STAND1, Expression::Id::DEFAULT); + else + speaker.draw(DrawArgument(center_pos, flip_speaker)); - speaker.draw(DrawArgument(center_pos, true)); nametag.draw(speaker_pos); name.draw(center_pos + Point(0, -4)); @@ -191,7 +201,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++) { @@ -240,7 +250,12 @@ namespace ms Point UINpcTalk::text_origin() const { - return Point(TEXT_X, text_y - offset * LINE); + return Point(text_x(), text_y - offset * LINE); + } + + int16_t UINpcTalk::text_x() const + { + return right_side ? TEXT_X - RIGHT_TEXT_SHIFT : TEXT_X; } Range UINpcTalk::visible_rows() const @@ -289,8 +304,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; } @@ -453,7 +475,7 @@ namespace ms return Cursor::State::CANCLICK; } - if (clicked && draw_text && Rectangle(TEXT_X, TEXT_X + TEXT_WIDTH, TOP, TOP + height).contains(cursor_relative)) + if (clicked && draw_text && Rectangle(text_x(), text_x() + TEXT_WIDTH, TOP, TOP + height).contains(cursor_relative)) { draw_text = false; return Cursor::State::IDLE; @@ -661,7 +683,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; @@ -680,10 +702,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) }; @@ -731,14 +756,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); @@ -774,16 +812,30 @@ namespace ms block = y; } - if (speakerbyte == 0) + // The speaker byte (dlg+0x6e0): 0x1 leaves out END CHAT, 0x2 has the + // player speak, 0x6 puts the speaker on the right, 0x8 mirrors the + // NPC. + player_speaks = speakerbyte & 0x2; + right_side = speakerbyte & 0x6; + flip_speaker = speakerbyte & 0x8; + + nl::node UtilDlgEx = NxFiles::ui_classic["UIWindow.img"]["UtilDlgEx"]; + std::string art = right_side ? "i" : ""; + + top = UtilDlgEx[art + "t"]; + fill = UtilDlgEx[art + "c"]; + bottom = UtilDlgEx[art + "s"]; + + if (player_speaks) + { + speaker = Texture(); + name.change_text(Stage::get().get_player().get_stats().get_name()); + } else { speaker = NxHelper::Npc::get_npc_node(npcid)["stand"]["0"]; std::string namestr = nl::nx::string["Npc.img"][std::to_string(npcid)]["name"]; name.change_text(namestr); - } else - { - speaker = Texture(); - name.change_text(""); } show_slider = block + PADDING > MAX_HEIGHT; @@ -793,7 +845,8 @@ namespace ms height = MAX_HEIGHT; text_y = SCROLL_TEXT_Y; rowmax = (block + LINE - 1) / LINE; - slider = Slider(Slider::Type::CLASSIC, Range(24, height + 14), 493, SCROLL_LINES, rowmax, onmoved); + slider = Slider(Slider::Type::CLASSIC, Range(24, height + 14), right_side ? 493 - RIGHT_SLIDER_SHIFT : 493, + SCROLL_LINES, rowmax, onmoved); } else { height = std::max(block + PADDING, MIN_HEIGHT); @@ -814,7 +867,7 @@ namespace ms // and BACK sit in the text area, clear of the scroll bar. int16_t bar_y = TOP + height + 32; int16_t text_button_y = TOP + height - 19; - int16_t text_button_x = show_slider ? -8 : 0; + int16_t text_button_x = (show_slider ? -8 : 0) - (right_side ? RIGHT_BUTTON_SHIFT : 0); auto place = [&](Buttons button, int16_t x, int16_t y) { @@ -822,7 +875,8 @@ namespace ms buttons[button]->set_active(true); }; - place(Buttons::CLOSE, 9, bar_y); + if (!(speakerbyte & 0x1)) + place(Buttons::CLOSE, 9, bar_y); switch (type) { diff --git a/HeavenClient/IO/UITypes/UINpcTalk.h b/HeavenClient/IO/UITypes/UINpcTalk.h index 2497f8042..d0ebb0072 100644 --- a/HeavenClient/IO/UITypes/UINpcTalk.h +++ b/HeavenClient/IO/UITypes/UINpcTalk.h @@ -99,6 +99,9 @@ namespace ms // Where the text starts, relative to the window, after scrolling. Point text_origin() const; + // The text's left edge, relative to the window. + int16_t text_x() const; + // The text area's rows, relative to the window. Range visible_rows() const; @@ -173,6 +176,11 @@ namespace ms bool show_slider; bool draw_text; + // From the speaker byte: who speaks, on which side, and whether the + // NPC is mirrored. + bool player_speaks = false; + bool right_side = false; + bool flip_speaker = false; Slider slider; TalkType type; Menu menu_kind; @@ -193,9 +201,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/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/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/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/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/UITypes/UIUserList.cpp b/HeavenClient/IO/UITypes/UIUserList.cpp index 2bee93f9b..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" @@ -709,7 +710,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: @@ -736,7 +748,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); @@ -746,10 +758,10 @@ 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(); + BuddyCommands::add(name, DEFAULT_GROUP); }, NAME_LIMIT ); @@ -760,11 +772,11 @@ 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) - DeleteBuddyPacket(id).dispatch(); + BuddyCommands::remove(id); } ); } @@ -940,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) @@ -948,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); @@ -1031,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/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/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/Handlers/BuddyHandlers.cpp b/HeavenClient/Net/Handlers/BuddyHandlers.cpp index eec3c9bcd..70fd671ee 100644 --- a/HeavenClient/Net/Handlers/BuddyHandlers.cpp +++ b/HeavenClient/Net/Handlers/BuddyHandlers.cpp @@ -2,8 +2,11 @@ #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" namespace ms @@ -13,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; @@ -32,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); @@ -52,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; @@ -60,27 +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)); - UI::get().emplace( - "'" + name + "' wants to add you as a buddy.\\nWould you like to accept?", - [id](bool yes) - { - if (yes) - AcceptBuddyPacket(id).dispatch(); - else - DeleteBuddyPacket(id).dispatch(); - } - ); + BuddyCommands::ask(name, id); break; } case Operation::OWN_LIST_FULL: @@ -98,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/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/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/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index efbbbf97f..0cdaeb1a9 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" @@ -31,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" @@ -151,12 +153,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 +260,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); + } } } @@ -460,39 +518,43 @@ namespace ms std::string from = recv.read_string(); recv.skip_byte(); int32_t messenger_id = recv.read_int(); + 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(); - 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(); - } - ); + if (!yes) + MessengerDeclinePacket(from, self).dispatch(); + else if (!open || !open->is_active()) + MessengerOpenPacket(messenger_id).dispatch(); + }); 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; } diff --git a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp index 588ae6819..cbef053a1 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,82 @@ 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; + + QuestConversation::confirmed(quest_id, npc_id, next_quest); - 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(); + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("The [" + name + "] quest expired because the time limit ended", + UIChatBar::LineType::GREY); - 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; + 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 @@ -81,18 +149,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) {}); @@ -104,8 +201,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/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/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/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/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/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/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 0c4f27fae..9b3db1a44 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); } }; @@ -373,7 +378,8 @@ namespace ms public: enum Operation : int8_t { - CREATE = 2 + CREATE = 2, + INVITE = 5 }; GuildOperationPacket(Operation op) : OutPacket(OutPacket::Opcode::GUILD_OPERATION) @@ -392,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/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/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/Util/NpcTextMarkup.h b/HeavenClient/Util/NpcTextMarkup.h index 07b367bde..2886ec94f 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': @@ -176,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/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/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/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/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/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/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/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/HeavenClient/tests/npc_text.cpp b/HeavenClient/tests/npc_text.cpp index 37b0b2e4d..fea8c10e3 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"); @@ -43,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_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..06d79f771 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,167 @@ 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"); + require(lead.speaker == 0, "Lana says the lead"); + 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"); + require(offer.page()->speaker == 2, "which the player says"); + 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 +186,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..b488c71b6 100644 --- a/HeavenClient/tests/quest_marker.cpp +++ b/HeavenClient/tests/quest_marker.cpp @@ -105,6 +105,52 @@ 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"); + // 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"); + 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 = {}; + 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/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/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/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/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/HeavenClient/tests/text_layout.cpp b/HeavenClient/tests/text_layout.cpp index 58bf89d0f..39a1194f8 100644 --- a/HeavenClient/tests/text_layout.cpp +++ b/HeavenClient/tests/text_layout.cpp @@ -10,13 +10,13 @@ void require(bool condition, const char* message) namespace { - // 6 px per character, the ellipsis 12, anything else 7. - int16_t advance(char32_t c) + // 6 px per character, the ellipsis 12, anything else 7; bold adds 1. + int16_t advance(char32_t c, bool bold) { if (c == 0x2026) - return 12; + return 12 + bold; - return c < 128 ? 6 : 7; + return (c < 128 ? 6 : 7) + bold; } ms::Text::Layout layout(const std::string &text, int16_t maxwidth, const std::vector> &boxes = {}) @@ -63,6 +63,20 @@ int main() require(line_count(box) == 2 && box.box(0).x() == 0, "a box wider than the line sits alone"); require(layout("a", 3).advance(1) == 6, "a lone character wider than the line is still placed"); + // #e makes text bold until #n: wider, and drawn in the bold font. + auto bold = layout("#ea b#nc", 100); + auto bold_at = [&bold](size_t pos) + { + for (const auto &line : bold) + for (const auto &word : line.words) + if (word.first <= pos && pos < word.last) + return word.bold; + + throw std::runtime_error("no word holds the character"); + }; + require(bold.advance(4) == 14 && bold.advance(7) == 21 && bold.width() == 27, "bold characters are wider"); + require(bold_at(2) && bold_at(4) && !bold_at(7), "words between #e and #n are bold"); + std::cout << "text layout tests passed\n"; return 0; } diff --git a/agent_docs/buddies.md b/agent_docs/buddies.md index f9ee44957..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,14 +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 `UIYesNo` (yes accepts, no 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 @@ -84,5 +98,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 378e53db9..c5c84cdac 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 @@ -97,6 +101,19 @@ 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. 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 + 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 @@ -179,7 +196,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 @@ -200,7 +218,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 @@ -230,6 +249,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 @@ -265,8 +303,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, @@ -422,8 +465,8 @@ 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 | -| Chat, whisper, `/find`, `/?`, `/party`, `/buddy`, `/all`, server messages | Implemented; buddy whisper/find modes ignored; other commands are dropped, as v83 does for non-GMs | +| 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` | | Buddy list (`UIUserList` Friend tab, buddy chat) | Implemented without groups; see `buddies.md` | @@ -432,7 +475,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 | @@ -571,12 +614,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 +639,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. @@ -660,9 +707,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/combat.md b/agent_docs/combat.md index 9a6924390..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 @@ -120,6 +125,33 @@ 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`, 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 they entered the map (`MapObjects::entry_order`), not the closest ones, so a @@ -284,7 +316,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 @@ -363,8 +397,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 @@ -818,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/agent_docs/inventory.md b/agent_docs/inventory.md index 095396b40..0e4d48f24 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -49,10 +49,20 @@ 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. +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 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 INVENTORY_OPERATION of swaps (mode 2), then GATHER_ITEM_RESULT (`0x34`) or @@ -72,6 +82,16 @@ 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. 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 timers, which restart whenever their conditions fail, and go out in separate @@ -200,8 +220,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/agent_docs/npc-dialogue.md b/agent_docs/npc-dialogue.md index e71f88c20..7341f2f84 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 @@ -61,7 +62,9 @@ applied, and apply nothing if the player no longer has them. Answers are matched Text comes from the [server content pack](./server-content.md) with placeholders. HeavenClient expands NPC text markup (`Util/NpcTextMarkup.h`: -`#h`, `#p`, `#t`/`#z`, `#o`, `#m`). +`#h`, `#p`, `#t`/`#z`, `#o`, `#m`). The text layout (`Graphics/TextLayout.cpp`) +handles the colours (`#k`, `#b`, `#g`, `#r`, `#c`, `#d`) and bold: `#e` switches +to the bold of the text's font and `#n` back. The dialog (`UINpcTalk`) is the v83 `UtilDlgEx` window from `UIClassic.nx`, laid out from period footage. It is centred on the screen, and its middle @@ -108,8 +111,14 @@ clear transient state. Responses without an active conversation are ignored. ## Wire format and client NpcDialogue (0x130) carries speaker category, NPC template ID, message type, -speaker portrait flag, text, and separate Back/Next bytes. Normal text uses -message type 0. Response 1 advances or finishes, 0 goes back, and -1 cancels. +speaker byte, text, and separate Back/Next bytes. Normal text uses message +type 0. Response 1 advances or finishes, 0 goes back, and -1 cancels. The +speaker byte's bits, as v83 reads them: 0x1 leaves out END CHAT, 0x2 shows +the player instead of the NPC, 0x6 (either bit) puts the speaker on the +right with the `UtilDlgEx/it`, `ic` and `is` frame and the text on the left, +and 0x8 mirrors the NPC. The server always sends 0. HeavenClient's quest +dialogs use 2 when the player answers a flipped question +(`Say.img///stop//flip`). HeavenClient uses the Back/Next bytes to choose buttons while retaining the wire message type for responses. Escape sends cancellation rather than Back. diff --git a/agent_docs/parties.md b/agent_docs/parties.md index 3d08f67e6..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,12 +61,17 @@ 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. 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. @@ -88,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/agent_docs/quests.md b/agent_docs/quests.md index 3d2b39218..983eef826 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -4,11 +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 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 +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)". If the NPC has a script (`Npc/.img/info/script`, + the last entry dated for today), an "ETC" line named by + `Etc/ScriptInfo.img/