diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index f6fe8ca7..4d3e16c8 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -258,6 +258,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME party_search COMMAND party_search_tests) add_executable(rps_game_tests tests/rps_game.cpp Character/RpsGame.cpp) add_test(NAME rps_game COMMAND rps_game_tests) + add_executable(family_tests tests/family.cpp Character/Family.cpp Net/InPacket.cpp Net/Handlers/Helpers/FamilyParser.cpp) + target_include_directories(family_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME family COMMAND family_tests) add_executable(diseases_tests tests/diseases.cpp) target_include_directories(diseases_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME diseases COMMAND diseases_tests) @@ -494,6 +497,8 @@ add_executable(HeavenClient Character/PartySearch.h Character/RpsGame.cpp Character/RpsGame.h + Character/Family.cpp + Character/Family.h Character/Pets.h Character/MiniRoomBalloon.cpp Character/MiniRoomBalloon.h @@ -744,6 +749,10 @@ add_executable(HeavenClient IO/UITypes/UIPartyHP.h IO/UITypes/UIPartySearch.cpp IO/UITypes/UIPartySearch.h + IO/UITypes/UIFamily.cpp + IO/UITypes/UIFamily.h + IO/UITypes/UIFamilyTree.cpp + IO/UITypes/UIFamilyTree.h IO/UITypes/UIPartySearchIcon.cpp IO/UITypes/UIPartySearchIcon.h IO/UITypes/UIQuestHelper.h @@ -927,6 +936,8 @@ add_executable(HeavenClient IO/PartyCommands.h IO/PartySearchActions.cpp IO/PartySearchActions.h + IO/FamilyActions.cpp + IO/FamilyActions.h IO/GuildCommands.cpp IO/GuildCommands.h IO/ChatCommand.h @@ -968,6 +979,8 @@ add_executable(HeavenClient Net/Handlers/Helpers/AllianceNews.h Net/Handlers/Helpers/GuildParser.cpp Net/Handlers/Helpers/GuildParser.h + Net/Handlers/Helpers/FamilyParser.cpp + Net/Handlers/Helpers/FamilyParser.h Net/Handlers/AttackHandlers.cpp Net/Handlers/AttackHandlers.h Net/Handlers/BuddyHandlers.cpp @@ -980,6 +993,8 @@ add_executable(HeavenClient Net/Handlers/CommonHandlers.h Net/Handlers/GuildHandlers.cpp Net/Handlers/GuildHandlers.h + Net/Handlers/FamilyHandlers.cpp + Net/Handlers/FamilyHandlers.h Net/Handlers/InventoryHandlers.cpp Net/Handlers/InventoryHandlers.h Net/Handlers/LoginHandlers.cpp @@ -1004,6 +1019,7 @@ add_executable(HeavenClient Net/Packets/CharCreationPackets.h Net/Packets/CommonPackets.h Net/Packets/GameplayPackets.h + Net/Packets/FamilyPackets.h Net/Packets/InventoryPackets.h Net/Packets/LoginPackets.h Net/Packets/MessagingPackets.h diff --git a/HeavenClient/Character/Family.cpp b/HeavenClient/Character/Family.cpp new file mode 100644 index 00000000..f11888df --- /dev/null +++ b/HeavenClient/Character/Family.cpp @@ -0,0 +1,189 @@ +#include "Family.h" + +#include +#include + +namespace ms +{ + bool FamilyPrivilege::needs_target() const + { + return type == 0 || type == 1; + } + + int32_t FamilyInfo::used_today(int32_t index) const + { + auto iter = used.find(index); + + return iter == used.end() ? 0 : iter->second; + } + + FamilyChart::FamilyChart() + { + slots.fill(-1); + } + + const FamilyNode* FamilyChart::at(size_t slot) const + { + if (slot >= SLOTS || slots[slot] < 0) + return nullptr; + + return &nodes[slots[slot]]; + } + + int32_t FamilyChart::count(int32_t key) const + { + auto iter = counts.find(key); + + return iter == counts.end() ? 0 : iter->second; + } + + bool FamilyChart::can_add_junior() const + { + const FamilyNode* owner = at(OWNER); + + return mine && owner && owner->juniors.size() < max_juniors; + } + + bool FamilyChart::passed_senior(size_t slot) const + { + if (!mine || slot < 5 || slot >= SLOTS) + return false; + + const FamilyNode* node = at(slot); + + if (!node || node->senior < 0) + return false; + + return node->level > nodes[node->senior].level; + } + + void FamilyChart::arrange(const FamilyNode& self) + { + slots.fill(-1); + + int32_t owner = -1; + std::unordered_map by_id; + + // The leader is never put in the map, so nobody hangs from it. + for (int32_t i = 1; i < static_cast(nodes.size()); i++) + { + FamilyNode& node = nodes[i]; + + if (node.id == viewed) + { + owner = i; + node.online = false; + } + + auto senior = by_id.find(node.senior_id); + + if (senior != by_id.end()) + { + node.senior = senior->second; + nodes[senior->second].juniors.push_back(i); + } + + by_id[node.id] = i; + } + + if (nodes.size() > 2) + slots[LEADER] = 0; + + if (owner < 0 && mine) + { + FamilyNode local; + local.id = self.id; + local.job = self.job; + local.level = self.level; + local.online = true; + local.name = self.name; + + owner = static_cast(nodes.size()); + nodes.push_back(local); + } + + if (owner < 0) + return; + + slots[OWNER] = owner; + + auto junior = [&](int32_t node, size_t index) + { + if (node < 0 || index >= nodes[node].juniors.size()) + return -1; + + return nodes[node].juniors[index]; + }; + + int32_t senior = nodes[owner].senior; + + if (senior >= 0) + { + slots[SENIOR] = senior; + slots[1] = nodes[senior].senior; + slots[4] = junior(senior, 1); + } + + for (size_t i = 0; i < 2; i++) + { + int32_t first = junior(owner, i); + slots[5 + i] = first; + slots[7 + 2 * i] = junior(first, 0); + slots[8 + 2 * i] = junior(first, 1); + } + } + + bool Family::loaded() const + { + return has_info; + } + + bool Family::exists() const + { + return has_info && !info.name.empty(); + } + + const FamilyInfo& Family::get_info() const + { + return info; + } + + const std::vector& Family::get_privileges() const + { + return privileges; + } + + bool Family::is_leader(int32_t self) const + { + return has_info && info.leader_id == self; + } + + bool Family::can_use(size_t index) const + { + if (!exists() || index >= privileges.size()) + return false; + + const FamilyPrivilege& privilege = privileges[index]; + + return info.rep >= privilege.cost + && info.used_today(static_cast(index)) < privilege.limit; + } + + void Family::set_info(FamilyInfo value) + { + info = std::move(value); + has_info = true; + } + + void Family::set_privileges(std::vector value) + { + privileges = std::move(value); + } + + void Family::clear() + { + has_info = false; + info = {}; + privileges.clear(); + } +} diff --git a/HeavenClient/Character/Family.h b/HeavenClient/Character/Family.h new file mode 100644 index 00000000..095b6e47 --- /dev/null +++ b/HeavenClient/Character/Family.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ms +{ + // An entitlement the family window offers, as the server lists them + // (FAMILY_PRIVILEGE_LIST). + struct FamilyPrivilege + { + uint8_t type = 0; + // The Rep it costs and how often it may be used a day. + int32_t cost = 0; + int32_t limit = 0; + std::string name; + std::string description; + + // Types 0 and 1 ask for a family member's name first. + bool needs_target() const; + }; + + // The player's own numbers (FAMILY_INFO_RESULT). + struct FamilyInfo + { + int32_t rep = 0; + int32_t total_rep = 0; + int32_t today = 0; + uint16_t juniors = 0; + uint16_t max_juniors = 0; + int32_t leader_id = 0; + // Empty outside a family. + std::string name; + std::string precepts; + // Times used today, by the privilege's place in the list. + std::map used; + + int32_t used_today(int32_t index) const; + }; + + // One member of a Family Tree. + struct FamilyNode + { + int32_t id = 0; + int32_t senior_id = 0; + uint16_t job = 0; + uint8_t level = 0; + bool online = false; + int32_t rep = 0; + int32_t total_rep = 0; + int32_t senior_rep = 0; + int32_t today = 0; + int32_t map_id = 0; + int32_t minutes = 0; + std::string name; + + // Places in FamilyChart::nodes, -1 for none. + int32_t senior = -1; + std::vector juniors; + }; + + // A Family Tree (FAMILY_CHART_RESULT), laid out as the client does + // (0x80a5f7): eleven boxes, four generations around the one viewed. + struct FamilyChart + { + static constexpr size_t SLOTS = 11; + static constexpr size_t LEADER = 0, SENIOR = 2, OWNER = 3; + + int32_t viewed = 0; + // Whether the tree is the player's own. + bool mine = false; + std::vector nodes; + // Places in `nodes` by box, -1 when empty. + std::array slots; + // -1: everyone in the family, 0: the seniors above the one viewed, + // a junior's id: everyone below that junior. + std::map counts; + std::map used; + uint16_t max_juniors = 0; + + FamilyChart(); + + // The member in a box, or nullptr. + const FamilyNode* at(size_t slot) const; + int32_t count(int32_t key) const; + // The Add Junior button: the player's own tree, with room below. + bool can_add_junior() const; + // Whether a member's name shows red: a junior of the player's tree + // who passed their senior's level. + bool passed_senior(size_t slot) const; + + // Links the members as the client does: the first is the leader at + // the top and nobody's senior, the one viewed is the owner, and the + // others hang from their seniors. Without the owner in the packet, + // the player's own tree gets one from `self`. + void arrange(const FamilyNode& self); + }; + + // The player's family, as the server last described it. + class Family + { + public: + // The longest precepts the prompt takes. + static constexpr size_t MAX_PRECEPTS = 200; + + // Every send but the two openers waits for the first INFO. + bool loaded() const; + bool exists() const; + const FamilyInfo& get_info() const; + const std::vector& get_privileges() const; + // Whether `self` may edit the precepts: the leader only. + bool is_leader(int32_t self) const; + // Whether the privilege at `index` may be used now. + bool can_use(size_t index) const; + + void set_info(FamilyInfo info); + void set_privileges(std::vector privileges); + void clear(); + + private: + bool has_info = false; + FamilyInfo info; + std::vector privileges; + }; +} diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 3895b307..21a1d9b2 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -2258,6 +2258,16 @@ namespace ms return alliance; } + Family &Player::get_family() + { + return family; + } + + const Family &Player::get_family() const + { + return family; + } + void Player::show_guild() { const GuildData &data = guild.get_data(); diff --git a/HeavenClient/Character/Player.h b/HeavenClient/Character/Player.h index c14da329..1246fcd7 100644 --- a/HeavenClient/Character/Player.h +++ b/HeavenClient/Character/Player.h @@ -24,6 +24,7 @@ #include "Diseases.h" #include "BuddyList.h" #include "Alliance.h" +#include "Family.h" #include "Guild.h" #include "ExclRequest.h" #include "MonsterBook.h" @@ -423,6 +424,10 @@ namespace ms // Show the guild's name and emblem under the player's name. void show_guild(); + // The player's family, as the server last described it. + Family &get_family(); + const Family &get_family() const; + private: // Whether the key for an action is held. bool is_held(KeyAction::Id action) const; @@ -512,6 +517,7 @@ namespace ms std::string linked_name; Guild guild; Alliance alliance; + Family family; EnumMap buffs; ActiveBuffs active_buffs; diff --git a/HeavenClient/Configuration.cpp b/HeavenClient/Configuration.cpp index 884ba78b..92866f11 100644 --- a/HeavenClient/Configuration.cpp +++ b/HeavenClient/Configuration.cpp @@ -79,6 +79,8 @@ namespace ms settings.emplace(); settings.emplace(); settings.emplace(); + settings.emplace(); + settings.emplace(); settings.emplace(); settings.emplace(); settings.emplace(); diff --git a/HeavenClient/Configuration.h b/HeavenClient/Configuration.h index ba1fd4c7..3770eedf 100644 --- a/HeavenClient/Configuration.h +++ b/HeavenClient/Configuration.h @@ -703,6 +703,21 @@ namespace ms {} }; + // The default position of UIFamily (v83's for type 0x1b, 0x49d1c4) + struct PosFAMILY : public Configuration::PointEntry + { + PosFAMILY() : PointEntry("PosFAMILY", "(244, 105)") + {} + }; + + // Where UIFamilyTree was last dragged; v83 opens it at (111, 107) every + // time. + struct PosFAMILYTREE : public Configuration::PointEntry + { + PosFAMILYTREE() : PointEntry("PosFAMILYTREE", "(111, 107)") + {} + }; + // The default position of UIChannel struct PosCHANNEL : public Configuration::PointEntry { diff --git a/HeavenClient/Graphics/Color.h b/HeavenClient/Graphics/Color.h index 64748b42..f43f7509 100644 --- a/HeavenClient/Graphics/Color.h +++ b/HeavenClient/Graphics/Color.h @@ -150,6 +150,7 @@ namespace ms JOBHEAD, JOBDISABLED, SCRIPTGOLD, + FAMILYBLUE, NUM_COLORS }; @@ -249,7 +250,8 @@ namespace ms {0.80f, 0.80f, 0.60f}, // Kite msg (#CCCC99, the item's words after a kite's flier) {0.47f, 0.50f, 0.53f}, // Job head (#777F88, a class's box in the Party Search window) {0.55f, 0.54f, 0.55f}, // Job disabled (#8C8A8C, a greyed box's label there) - {1.00f, 0.78f, 0.00f} // Script gold (#FFC600, the floating script lines) + {1.00f, 0.78f, 0.00f}, // Script gold (#FFC600, the floating script lines) + {0.64f, 0.94f, 1.00f} // Family blue (#A4F0FF, v83 font 0x10: an empty junior's box in the Family Tree) }; // Create a color by an array of real numbers [0.0f, 1.0f] diff --git a/HeavenClient/IO/Components/Tooltip.h b/HeavenClient/IO/Components/Tooltip.h index 7ed54e5a..a3c7ae94 100644 --- a/HeavenClient/IO/Components/Tooltip.h +++ b/HeavenClient/IO/Components/Tooltip.h @@ -65,7 +65,9 @@ namespace ms HELP, // The item search's results, whose tooltips go right of the cursor. PETITEMSEARCH, - DUEY + DUEY, + FAMILY, + FAMILYTREE }; virtual ~Tooltip() diff --git a/HeavenClient/IO/FamilyActions.cpp b/HeavenClient/IO/FamilyActions.cpp new file mode 100644 index 00000000..04d84564 --- /dev/null +++ b/HeavenClient/IO/FamilyActions.cpp @@ -0,0 +1,80 @@ +#include "FamilyActions.h" + +#include "UI.h" + +#include "UITypes/UIChatBar.h" +#include "UITypes/UIFamily.h" +#include "UITypes/UIFamilyTree.h" +#include "UITypes/UINotice.h" + +#include "../Gameplay/Stage.h" +#include "../Net/Packets/FamilyPackets.h" + +namespace ms +{ + namespace FamilyActions + { + namespace + { + // StringPool 0x11e9 and 0x11f5. + constexpr const char* ADD_PROMPT = "Enter the name of the character you wish to add as Junior. You must be in the same channel as well as map to add."; + constexpr const char* CANNOT_ADD = "You cannot add this character as a Junior."; + // A character's name. + constexpr size_t NAME_LIMIT = 12; + } + + void toggle_window() + { + UIFamily::toggle(); + } + + void open_tree(const std::string& name) + { + auto tree = UI::get().get_element(); + + if (!tree || !tree->is_active()) + UI::get().emplace(); + + OpenFamilyPedigreePacket(name).dispatch(); + } + + void prompt_junior() + { + UI::get().emplace(ADD_PROMPT, [](const std::string& name) + { + add_junior(name); + }, NAME_LIMIT); + } + + void add_junior(const std::string& name) + { + Player& player = Stage::get().get_player(); + + if (!player.get_family().loaded()) + return; + + if (name == player.get_stats().get_name()) + { + UI::get().emplace(CANNOT_ADD, [](bool) {}); + + return; + } + + AddFamilyPacket(name).dispatch(); + } + + bool can_add_junior() + { + const Family& family = Stage::get().get_player().get_family(); + const FamilyInfo& info = family.get_info(); + + return family.loaded() && info.juniors < info.max_juniors; + } + + void chat_line(const std::string& text) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(text, UIChatBar::LineType::SKILL); + } + } +} diff --git a/HeavenClient/IO/FamilyActions.h b/HeavenClient/IO/FamilyActions.h new file mode 100644 index 00000000..de6a307f --- /dev/null +++ b/HeavenClient/IO/FamilyActions.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace ms +{ + // What the Family windows, the character info window, the avatar menu + // and the Family key share (reports b042, b044, b045). + namespace FamilyActions + { + // The Family key and BtFamily on the player's own info: opens the + // Family window or closes it (ToggleUI 0x1b). + void toggle_window(); + // Opens the Family Tree if it isn't open (OpenUI 0x1c) and asks for + // `name`'s. + void open_tree(const std::string& name); + // The Add Junior prompt (0x8082fe). + void prompt_junior(); + // Asks `name` to be a junior (0xa340f6): nothing before the first + // INFO, and a refusal without asking for the player's own name. + void add_junior(const std::string& name); + // Whether the avatar menu has its Family row (0x9504ae): the + // family is known and has room below the player. + bool can_add_junior(); + // A family text in the chat log, as v83's type 0xc. + void chat_line(const std::string& text); + } +} diff --git a/HeavenClient/IO/KeyAction.h b/HeavenClient/IO/KeyAction.h index 92ceae66..6beed296 100644 --- a/HeavenClient/IO/KeyAction.h +++ b/HeavenClient/IO/KeyAction.h @@ -51,6 +51,7 @@ namespace ms GUILDCHAT = 25, // v83's 25, which this enum gives to guild chat. PARTYSEARCH = 26, + FAMILY = 27, MEDALS = 28, BITS, ALLIANCECHAT, diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index 23ac5c09..819e4b09 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -437,6 +437,8 @@ namespace ms types.emplace_back(UIElement::Type::SLIDEMENU); types.emplace_back(UIElement::Type::KITEWRITE); types.emplace_back(UIElement::Type::KITEVIEW); + types.emplace_back(UIElement::Type::FAMILY); + types.emplace_back(UIElement::Type::FAMILYTREE); } else if (enter) { diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index a954b039..2147dcd5 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -124,6 +124,8 @@ namespace ms PARTYSEARCHICON, RPSGAME, SCRIPTMESSAGE, + FAMILY, + FAMILYTREE, NUM_TYPES }; diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index f169cbfa..af1d360d 100644 --- a/HeavenClient/IO/UIStateGame.cpp +++ b/HeavenClient/IO/UIStateGame.cpp @@ -20,6 +20,7 @@ #include "UI.h" #include "InputFocus.h" +#include "FamilyActions.h" #include "Components/TooltipBox.h" #include "UITypes/UIBuffList.h" @@ -79,6 +80,10 @@ namespace ms return Tooltip::Parent::STORAGE; case UIElement::Type::DUEY: return Tooltip::Parent::DUEY; + case UIElement::Type::FAMILY: + return Tooltip::Parent::FAMILY; + case UIElement::Type::FAMILYTREE: + return Tooltip::Parent::FAMILYTREE; case UIElement::Type::TRADINGROOM: return Tooltip::Parent::TRADINGROOM; case UIElement::Type::PERSONALSHOP: @@ -393,6 +398,9 @@ namespace ms case KeyAction::Id::PARTYSEARCH: UIPartySearch::toggle(); break; + case KeyAction::Id::FAMILY: + FamilyActions::toggle_window(); + break; case KeyAction::Id::MAPLECHAT: UIMessenger::toggle(); break; diff --git a/HeavenClient/IO/UITypes/UICharInfo.cpp b/HeavenClient/IO/UITypes/UICharInfo.cpp index 09d4f909..9dd664fb 100644 --- a/HeavenClient/IO/UITypes/UICharInfo.cpp +++ b/HeavenClient/IO/UITypes/UICharInfo.cpp @@ -24,6 +24,7 @@ #include "../../Data/EquipData.h" #include "../../Data/ItemData.h" #include "../../Gameplay/Stage.h" +#include "../../IO/FamilyActions.h" #include "../../IO/PartyCommands.h" #include "../../IO/UI.h" #include "../../IO/UITypes/UIChatBar.h" @@ -47,7 +48,7 @@ namespace ms constexpr int16_t TAMING_X = 5, PET_X = 93, COLLECTION_X = 145, EXCEPTION_X = 199; const Point CLOSE = { 256, 6 }; - const Point FAMILY = { 105, 23 }; + const Point FAMILY_AT = { 105, 23 }; const Point PARTY = { 149, 23 }; const Point TRADE = { 149, 42 }; const Point ITEM = { 208, 22 }; @@ -134,7 +135,7 @@ namespace ms wish_list = UserInfo["backgrnd3"]; buttons[Buttons::BT_CLOSE] = std::make_unique(Basic["BtClose"], CLOSE); - buttons[Buttons::BT_FAMILY] = std::make_unique(UserInfo["BtFamily"], FAMILY); + buttons[Buttons::BT_FAMILY] = std::make_unique(UserInfo["BtFamily"], FAMILY_AT); buttons[Buttons::BT_PARTY] = std::make_unique(UserInfo["BtParty"], PARTY); buttons[Buttons::BT_TRADE] = std::make_unique(UserInfo["BtTrade"], TRADE); buttons[Buttons::BT_ITEM] = std::make_unique(UserInfo["BtItem"], ITEM); @@ -168,9 +169,9 @@ namespace ms }); taming_slider = Slider(Slider::Type::CLASSIC, TAMING_SLIDER, ITEM_SLIDER_X, ITEM_ROWS, 0, [](bool) {}); - // There are no families yet. The pet and taming boxes open once - // CHAR_INFO says there are pets or a mount. - for (auto button : { Buttons::BT_FAMILY, Buttons::BT_EXCEPTION, Buttons::BT_PET_SHOW, Buttons::BT_TAMING_SHOW }) + // The pet and taming boxes open once CHAR_INFO says there are pets + // or a mount. BtFamily is always on (report b045). + for (auto button : { Buttons::BT_EXCEPTION, Buttons::BT_PET_SHOW, Buttons::BT_TAMING_SHOW }) buttons[button]->set_state(Button::State::DISABLED); // v83 offers fame only from level 15 (FUN_009007a0), and never for @@ -337,6 +338,13 @@ namespace ms case Buttons::BT_PANEL_HIDE: set_panel(Panel::NO_PANEL); break; + case Buttons::BT_FAMILY: + // The player's own opens the Family window, another's their tree. + if (name.get_text() == Stage::get().get_player().get_stats().get_name()) + FamilyActions::toggle_window(); + else + FamilyActions::open_tree(name.get_text()); + break; case Buttons::BT_FAME_UP: give_fame(true); break; diff --git a/HeavenClient/IO/UITypes/UIContextMenu.cpp b/HeavenClient/IO/UITypes/UIContextMenu.cpp index 7f61f3ab..bec65ddf 100644 --- a/HeavenClient/IO/UITypes/UIContextMenu.cpp +++ b/HeavenClient/IO/UITypes/UIContextMenu.cpp @@ -5,6 +5,7 @@ #include "UINotice.h" #include "UITradingRoom.h" +#include "../FamilyActions.h" #include "../GuildCommands.h" #include "../PartyCommands.h" #include "../UI.h" @@ -40,12 +41,6 @@ namespace ms const Point TITLE_AT(11, -3); constexpr size_t TITLE_WIDTH = 78; - // Juniors come from the family, which HC doesn't have. - bool can_add_junior() - { - return false; - } - } UIContextMenu::UIContextMenu(int32_t c, const std::string& n, Point cursor) : @@ -63,7 +58,7 @@ namespace ms bool party_invite = !party.exists() || party.is_leader(player.get_oid()); std::vector entries = - context_menu::entries(party_invite, GuildCommands::can_invite(player), can_add_junior()); + context_menu::entries(party_invite, GuildCommands::can_invite(player), FamilyActions::can_add_junior()); rows = entries.size(); for (size_t row = 0; row < rows; row++) @@ -160,6 +155,9 @@ namespace ms case context_menu::GUILD: GuildCommands::invite(name); break; + case context_menu::FAMILY: + FamilyActions::add_junior(name); + break; case context_menu::CLAIM: // The server never opens reports, so v83 says they're down // (FUN_00a2719c). diff --git a/HeavenClient/IO/UITypes/UIFamily.cpp b/HeavenClient/IO/UITypes/UIFamily.cpp new file mode 100644 index 00000000..ba00296b --- /dev/null +++ b/HeavenClient/IO/UITypes/UIFamily.cpp @@ -0,0 +1,401 @@ +#include "UIFamily.h" + +#include "UINotice.h" + +#include "../FamilyActions.h" +#include "../UI.h" + +#include "../Components/MapleButton.h" + +#include "../../Gameplay/Stage.h" +#include "../../Net/Packets/FamilyPackets.h" +#include "../../Util/Misc.h" +#include "../../Util/NxFiles.h" + +#include + +#ifdef USE_NX +#include +#endif + +namespace ms +{ + namespace + { + // Window-local, from b042. v83 draws a line's cap top about 3 below + // where it is drawn, we 9, so text goes up by the difference. + constexpr int16_t TEXT_RAISE = 6; + const Point DRAG_AREA(224, 25); + + const Point JUNIOR_AT(95, 77), TREE_AT(164, 77), LEFT_AT(15, 177), RIGHT_AT(196, 177); + const Point PRECEPTS_BUTTON_AT(209, 55), SPECIAL_AT(152, 322), OK_AT(93, 368); + + // Each text's left edge and width; centred or right-aligned in it. + constexpr int16_t TITLE_X = 7, TITLE_Y = 33, TITLE_W = 209; + constexpr int16_t JUNIORS_X = 52, JUNIORS_Y = 81, JUNIORS_W = 37; + constexpr int16_t REPS_X = 54, REPS_Y = 111, REPS_W = 159; + constexpr int16_t TODAY_X = 106, TODAY_Y = 128, TODAY_W = 107; + constexpr int16_t NAME_X = 15, NAME_Y = 178, NAME_W = 193; + constexpr int16_t COST_X = 74, COST_Y = 198, COST_W = 43; + constexpr int16_t USED_X = 175, USED_Y = 198, USED_W = 32; + constexpr int16_t PAGE_X = 158, PAGE_Y = 158; + constexpr int16_t DESCRIPTION_X = 16, DESCRIPTION_Y = 214, DESCRIPTION_W = 195; + + // The precepts scroll through a 194x15 strip, a pixel a step, and + // start again from its right edge once gone (0x8093fc). + constexpr int16_t PRECEPTS_X = 9, PRECEPTS_Y = 60, PRECEPTS_W = 194; + // I: v83 moves them each of its updates; we take one every four of + // our 8 ms steps. + constexpr uint16_t PRECEPTS_STEPS = 4; + + const Rectangle REPS_TIP(6, 217, 108, 140); + const Rectangle JUNIORS_TIP(6, 93, 78, 93); + const Rectangle USED_TIP(112, 209, 194, 209); + + // StringPool 0x11f1, 0x11ef, 0x11eb, 0x120f. + constexpr const char* NO_FAMILY = "(You do not have a family yet.)"; + constexpr const char* PRECEPTS_PROMPT = "Enter the family precepts."; + constexpr const char* TARGET_PROMPT = "Please enter the name of the targeted family member."; + // 0x1213, 0x1214, 0x1219, 0x121a. + constexpr const char* REPS_HELP = "Reps are marked as [Current Rep/Total Rep].\\nUse up your current Reps to acquire [Entitlements]."; + constexpr const char* JUNIORS_HELP = "Juniors are marked as [Current Juniors/Max Juniors Available].\\n When a Junior you have registered under gains EXP or levels up, your Rep will increase as well.\\nHelp your Junior level up to further increase your Rep."; + constexpr const char* USED_UP = "You cannot use any more Entitlements today."; + constexpr size_t NAME_LIMIT = 12; + + std::string number(int32_t value) + { + std::string text = std::to_string(value); + string_format::split_number(text); + + return text; + } + + Text line(Text::Alignment alignment, const std::string& text = "") + { + return Text(Text::Font::A12M, alignment, Color::Name::BLACK, text); + } + + const Family& family() + { + return Stage::get().get_player().get_family(); + } + + // Inclusive of the bottom and right edges, as PtInRect is not; + // near enough for a tooltip. + bool inside(const Rectangle& rect, Point local) + { + return local.x() >= rect.left() && local.x() < rect.right() + && local.y() >= rect.top() && local.y() < rect.bottom(); + } + } + + UIFamily::UIFamily() : UIDragElement(DRAG_AREA) + { + nl::node src = NxFiles::ui_classic["UIWindow.img"]["Family"]; + + background = src["backgrnd"]; + dimension = background.get_dimensions(); + + buttons[JUNIOR] = std::make_unique(src["BtJuniorEntry"], JUNIOR_AT); + buttons[TREE] = std::make_unique(src["BtTree"], TREE_AT); + buttons[LEFT] = std::make_unique(src["BtLeft"], LEFT_AT); + buttons[RIGHT] = std::make_unique(src["BtRight"], RIGHT_AT); + buttons[PRECEPTS] = std::make_unique(src["BtFamilyPrecept"], PRECEPTS_BUTTON_AT); + buttons[SPECIAL] = std::make_unique(src["BtSpecial"], SPECIAL_AT); + buttons[OK] = std::make_unique(src["BtOK"], OK_AT); + + title = line(Text::Alignment::CENTER); + juniors = line(Text::Alignment::RIGHT); + reps = line(Text::Alignment::RIGHT); + today = line(Text::Alignment::RIGHT); + name = line(Text::Alignment::CENTER); + cost = line(Text::Alignment::RIGHT); + used = line(Text::Alignment::RIGHT); + page = line(Text::Alignment::LEFT); + // One line however long, as it scrolls. + precepts = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, "", INT16_MAX, false); + + refresh(); + } + + void UIFamily::toggle() + { + auto window = UI::get().get_element(); + bool opening = !window || !window->is_active(); + + UI::get().emplace(); + + if (!opening) + return; + + // v83 builds the window afresh each time, and asks for the + // numbers as it does (0x808a1a). + if (auto opened = UI::get().get_element()) + { + opened->selected = 0; + opened->precepts_x = PRECEPTS_W; + opened->refresh(); + } + + OpenFamilyPacket().dispatch(); + } + + void UIFamily::refresh() + { + const FamilyInfo& info = family().get_info(); + + if (info.name.empty()) + title.change_text(NO_FAMILY); + else + title.change_text(info.name + " Family"); + + juniors.change_text(std::to_string(info.juniors) + "/" + std::to_string(info.max_juniors)); + reps.change_text(number(info.rep) + " / " + number(info.total_rep)); + today.change_text(info.today >= 0 ? "+" + number(info.today) : number(info.today)); + + if (precepts.get_text() != info.precepts) + { + precepts.change_text(info.precepts); + precepts_x = PRECEPTS_W; + } + + int32_t self = Stage::get().get_player().get_oid(); + buttons[PRECEPTS]->set_state(family().is_leader(self) ? Button::State::NORMAL : Button::State::DISABLED); + + show_privilege(); + enable_buttons(); + } + + void UIFamily::show_privilege() + { + const std::vector& privileges = family().get_privileges(); + + if (selected >= privileges.size()) + selected = 0; + + if (privileges.empty()) + return; + + const FamilyPrivilege& privilege = privileges[selected]; + int32_t times = family().get_info().used_today(static_cast(selected)); + + name.change_text(privilege.name); + cost.change_text(number(privilege.cost)); + used.change_text(std::to_string(times) + " / " + std::to_string(privilege.limit)); + + char counter[16]; + std::snprintf(counter, sizeof(counter), "[%02d/%02d]", static_cast(selected + 1), static_cast(privileges.size())); + page.change_text(counter); + + description = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, privilege.description, DESCRIPTION_W); + } + + void UIFamily::enable_buttons() + { + const FamilyInfo& info = family().get_info(); + + auto enable = [this](Buttons id, bool on) + { + Button::State state = buttons[id]->get_state(); + + if (!on) + buttons[id]->set_state(Button::State::DISABLED); + else if (state == Button::State::DISABLED) + buttons[id]->set_state(Button::State::NORMAL); + }; + + enable(JUNIOR, info.juniors < info.max_juniors); + enable(SPECIAL, family().can_use(selected)); + } + + void UIFamily::draw(float inter) const + { + background.draw(position); + + UIElement::draw(inter); + + Point raise(0, TEXT_RAISE); + + title.draw(position + Point(TITLE_X + TITLE_W / 2, TITLE_Y) - raise); + juniors.draw(position + Point(JUNIORS_X + JUNIORS_W, JUNIORS_Y) - raise); + reps.draw(position + Point(REPS_X + REPS_W, REPS_Y) - raise); + today.draw(position + Point(TODAY_X + TODAY_W, TODAY_Y) - raise); + + if (!precepts.empty()) + { + int16_t left = position.x() + PRECEPTS_X; + + precepts.draw_between(position + Point(PRECEPTS_X + precepts_x, PRECEPTS_Y) - raise, + Range(left, left + PRECEPTS_W)); + } + + if (family().get_privileges().empty()) + return; + + name.draw(position + Point(NAME_X + NAME_W / 2, NAME_Y) - raise); + cost.draw(position + Point(COST_X + COST_W, COST_Y) - raise); + used.draw(position + Point(USED_X + USED_W, USED_Y) - raise); + page.draw(position + Point(PAGE_X, PAGE_Y) - raise); + description.draw(position + Point(DESCRIPTION_X, DESCRIPTION_Y) - raise); + } + + void UIFamily::update() + { + UIElement::update(); + + if (!precepts.empty() && ++precepts_step >= PRECEPTS_STEPS) + { + precepts_step = 0; + precepts_x--; + + if (precepts_x + precepts.width() < 0) + precepts_x = PRECEPTS_W; + } + + enable_buttons(); + } + + Cursor::State UIFamily::send_cursor(bool clicked, Point cursorpos) + { + Cursor::State state = UIDragElement::send_cursor(clicked, cursorpos); + + if (dragged) + { + clear_tooltip(); + + return state; + } + + Point local = cursorpos - position; + std::string text; + + if (inside(REPS_TIP, local)) + { + text = REPS_HELP; + } + else if (inside(JUNIORS_TIP, local)) + { + text = JUNIORS_HELP; + } + else if (inside(USED_TIP, local) && !family().get_privileges().empty()) + { + const FamilyPrivilege& privilege = family().get_privileges()[selected]; + int32_t times = family().get_info().used_today(static_cast(selected)); + + if (times >= privilege.limit) + text = USED_UP; + else + text = "Entitlement used today: " + std::to_string(times) + + "\\nEntitlement available today: " + std::to_string(privilege.limit - times); + } + + if (text.empty()) + { + clear_tooltip(); + } + else + { + UI::get().show_text(Tooltip::Parent::FAMILY, text); + tooltip = true; + } + + return state; + } + + void UIFamily::remove_cursor() + { + UIDragElement::remove_cursor(); + + clear_tooltip(); + } + + void UIFamily::clear_tooltip() + { + if (!tooltip) + return; + + tooltip = false; + UI::get().clear_tooltip(Tooltip::Parent::FAMILY); + } + + void UIFamily::send_key(int32_t, bool pressed, bool escape) + { + if (pressed && escape) + { + clear_tooltip(); + deactivate(); + } + } + + Button::State UIFamily::button_pressed(uint16_t buttonid) + { + size_t count = family().get_privileges().size(); + + switch (buttonid) + { + case JUNIOR: + FamilyActions::prompt_junior(); + break; + case TREE: + FamilyActions::open_tree(Stage::get().get_player().get_stats().get_name()); + break; + case LEFT: + if (count > 0) + selected = (selected + count - 1) % count; + + show_privilege(); + break; + case RIGHT: + if (count > 0) + selected = (selected + 1) % count; + + show_privilege(); + break; + case PRECEPTS: + edit_precepts(); + break; + case SPECIAL: + use_privilege(); + break; + case OK: + deactivate(); + break; + } + + return Button::State::NORMAL; + } + + void UIFamily::use_privilege() + { + const std::vector& privileges = family().get_privileges(); + + if (!family().loaded() || selected >= privileges.size()) + return; + + int32_t index = static_cast(selected); + + if (!privileges[selected].needs_target()) + return UseFamilyPacket(index).dispatch(); + + UI::get().emplace(TARGET_PROMPT, [index](const std::string& target) + { + UseFamilyPacket(index, target).dispatch(); + }, NAME_LIMIT); + } + + void UIFamily::edit_precepts() + { + if (!family().loaded()) + return; + + UI::get().emplace(PRECEPTS_PROMPT, [](const std::string& text) + { + ChangeFamilyMessagePacket(text).dispatch(); + }, Family::MAX_PRECEPTS); + } + + UIElement::Type UIFamily::get_type() const + { + return TYPE; + } +} diff --git a/HeavenClient/IO/UITypes/UIFamily.h b/HeavenClient/IO/UITypes/UIFamily.h new file mode 100644 index 00000000..65c16844 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIFamily.h @@ -0,0 +1,61 @@ +#pragma once + +#include "../UIDragElement.h" + +#include "../../Graphics/Text.h" +#include "../../Graphics/Texture.h" + +namespace ms +{ + // v83's Family window (UIWindow.img/Family, type 0x1b, report b042): the + // family's name and precepts, the player's juniors and Rep, and the + // entitlements one at a time. Opening it sends OPEN_FAMILY; the server + // answers with INFO and the entitlements. OK closes it, and so does Esc + // when it is the topmost window (b045). + class UIFamily : public UIDragElement + { + public: + static constexpr Type TYPE = UIElement::Type::FAMILY; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = true; + + UIFamily(); + + // Opens or closes it, as the Family key does (ToggleUI 0x1b). + static void toggle(); + + void draw(float inter) const override; + void update() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + void remove_cursor() override; + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + // INFO or the entitlements came (0x809820). + void refresh(); + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + enum Buttons : uint16_t { JUNIOR, TREE, LEFT, RIGHT, PRECEPTS, SPECIAL, OK }; + + void show_privilege(); + void enable_buttons(); + void use_privilege(); + void edit_precepts(); + void clear_tooltip(); + + Texture background; + Text title, juniors, reps, today; + Text name, cost, used, page, description; + Text precepts; + // The precepts' left edge, as they scroll across. + int16_t precepts_x = 0; + uint16_t precepts_step = 0; + size_t selected = 0; + bool tooltip = false; + }; +} diff --git a/HeavenClient/IO/UITypes/UIFamilyTree.cpp b/HeavenClient/IO/UITypes/UIFamilyTree.cpp new file mode 100644 index 00000000..a0555e8f --- /dev/null +++ b/HeavenClient/IO/UITypes/UIFamilyTree.cpp @@ -0,0 +1,420 @@ +#include "UIFamilyTree.h" + +#include "../FamilyActions.h" +#include "../UI.h" + +#include "../Components/MapleButton.h" + +#include "../../Character/Job.h" +#include "../../Gameplay/Stage.h" +#include "../../Util/Misc.h" +#include "../../Util/NxFiles.h" + +#ifdef USE_NX +#include +#endif + +namespace ms +{ + namespace + { + // Window-local, from b044. As in the Family window, text goes up + // by the difference between where v83 and we draw a line's top. + constexpr int16_t TEXT_RAISE = 6; + const Point POSITION(111, 107); + const Point DRAG_AREA(578, 25); + + const Point JUNIOR_AT(21, 57), BYE_AT(71, 57); + + // The boxes (0xbe2778): the leader, two seniors, the one viewed and + // beside them their senior's other junior, then two generations of + // juniors. + const std::array, FamilyChart::SLOTS> SLOT_AT = { + Point(222, 40), Point(222, 94), Point(222, 146), + Point(222, 198), Point(365, 198), + Point(80, 250), Point(365, 250), + Point(13, 302), Point(148, 302), Point(297, 302), Point(432, 302) + }; + constexpr int16_t SLOT_W = 133, SLOT_H = 34; + // The level line is wider than its box and starts left of it. + constexpr int16_t LEVEL_X = -10, LEVEL_W = 153; + constexpr int16_t GLOW_MARGIN = 3; + + constexpr int16_t TITLE_X = 205, TITLE_Y = 10, TITLE_W = 167; + constexpr int16_t TOTAL_X = 29, TOTAL_Y = 41, TOTAL_W = 72; + const Point SENIORS_AT(295, 80); + const Rectangle TOTAL_TIP(19, 120, 20, 53); + + // StringPool 0x11f1, 0x11ef, 0x11ec, 0x11f0, 0x11f2, 0x11f3. + constexpr const char* NO_FAMILY = "(You do not have a family yet.)"; + constexpr const char* NO_MEMBERS = "(You do not have family members.)"; + constexpr const char* ADD_JUNIOR = "[Please add a Junior.]"; + + // 0x1215-0x1218. + constexpr const char* JUNIOR_HELP = "If the Junior slot is empty, then you can add a Junior that will help enhance your Rep."; + constexpr const char* SENIOR_BYE_HELP = "Your Senior will sever ties and start a new family.\\nThe members below you will belong to your family."; + constexpr const char* JUNIOR_BYE_HELP = "This will end your relationship with your Junior.\\nThe Junior and everyone underneath will all be removed from the Family."; + constexpr const char* TOTAL_HELP = "This covers everyone in the Family.\\nThe number may be higher than the one shown in the Family Tree."; + + // 0x121b-0x1225, as the member tooltips build them (0x80bb81, + // 0x80bf45). v83 indents the uses with a tab. + constexpr const char* PASSED_SENIOR = "#c* You have surpassed your Senior's level, so your cumulative Rep#\\n#cwill be reduced in half. Please help your Senior level up!#\\n"; + constexpr const char* PASSED_YOU = "#c* Your Junior has surpassed your level, so your cumulative Rep#\\n#cwill be reduced in half. Please try harder to level up!#\\n"; + constexpr const char* USED_TODAY = "Entitlement used today :\\n"; + constexpr const char* SEPARATOR = "______________________________________________\\n"; + // 0x2ce: where a member is with no map. + constexpr const char* CASH_SHOP = "Cash Shop"; + + std::string number(int32_t value) + { + std::string text = std::to_string(value); + string_format::split_number(text); + + return text; + } + + // Centred in a box `width` wide. v83 halves the room left over as + // unsigned (0x808212), so a text wider than its box lands far off + // screen and is never seen. + Text centred(Text::Font font, Color::Name color, const std::string& text = "", uint16_t width = SLOT_W) + { + Text line(font, Text::Alignment::CENTER, color, text, 0, false); + + return line.width() > width ? Text() : line; + } + + bool inside(const Rectangle& rect, Point local) + { + return local.x() >= rect.left() && local.x() < rect.right() + && local.y() >= rect.top() && local.y() < rect.bottom(); + } + } + + UIFamilyTree::UIFamilyTree() : UIDragElement(DRAG_AREA) + { + // v83 opens it in the same place each time. + position = POSITION; + + nl::node src = NxFiles::ui_classic["UIWindow.img"]["FamilyTree"]; + + background = src["backgrnd"]; + selected_glow = src["selected"]; + dimension = background.get_dimensions(); + + for (size_t i = 0; i < 2; i++) + { + leader_plate[i] = src["PlateLeader"][std::to_string(i)]; + other_plate[i] = src["PlateOthers"][std::to_string(i)]; + } + + buttons[JUNIOR] = std::make_unique(src["BtJuniorEntry"], JUNIOR_AT); + buttons[BYE] = std::make_unique(src["BtBye"], BYE_AT); + + lay_out(); + } + + void UIFamilyTree::set_chart(FamilyChart value) + { + chart = std::move(value); + selected = -1; + hovered = -1; + + lay_out(); + } + + void UIFamilyTree::set_online(const std::string& name, bool online) + { + for (FamilyNode& node : chart.nodes) + if (node.name == name) + node.online = online; + } + + bool UIFamilyTree::is_mine() const + { + return chart.mine; + } + + void UIFamilyTree::lay_out() + { + const FamilyNode* leader = chart.at(FamilyChart::LEADER); + + title = centred(Text::Font::A12M, Color::Name::WHITE, leader ? leader->name + " Family" : NO_FAMILY, TITLE_W); + total = Text(Text::Font::A12M, Text::Alignment::RIGHT, Color::Name::BLACK, leader ? number(chart.count(-1)) : "0", TOTAL_W); + + int32_t above = chart.count(0); + + if (chart.at(1)) + above--; + + if (chart.at(FamilyChart::SENIOR)) + above--; + + seniors = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::WHITE, "Senior(" + std::to_string(above) + " ppl.)"); + + for (size_t slot = 0; slot < FamilyChart::SLOTS; slot++) + { + const FamilyNode* node = chart.at(slot); + + names[slot] = Text(); + levels[slot] = Text(); + empties[slot] = Text(); + juniors[slot] = Text(); + + if (node) + { + Color::Name color = chart.passed_senior(slot) ? Color::Name::BRIGHTRED : Color::Name::WHITE; + + names[slot] = centred(Text::Font::A12M, color, node->name); + levels[slot] = centred(Text::Font::A11M, Color::Name::WHITE, + "Lv." + std::to_string(node->level) + " " + Job::get_name(node->job), LEVEL_W); + } + else if (slot == 5 || slot == 6) + { + empties[slot] = centred(Text::Font::A12M, Color::Name::FAMILYBLUE, ADD_JUNIOR); + } + else if (slot != FamilyChart::OWNER) + { + // Wider than the box, so blank as in v83 (see centred). + empties[slot] = centred(Text::Font::A12M, Color::Name::SILVER, NO_MEMBERS); + } + + // Shown for an empty box too, with none. + if (slot >= 7) + { + int32_t below = node ? chart.count(node->id) : 0; + juniors[slot] = centred(Text::Font::A12M, Color::Name::WHITE, "Junior(" + std::to_string(below) + " ppl.)"); + } + } + + buttons[JUNIOR]->set_state(chart.can_add_junior() ? Button::State::NORMAL : Button::State::DISABLED); + // Severing ties comes with the separation packets; until then the + // button stays off. + buttons[BYE]->set_state(Button::State::DISABLED); + } + + void UIFamilyTree::draw(float inter) const + { + background.draw(position); + + Point raise(0, TEXT_RAISE); + + for (size_t slot = 0; slot < FamilyChart::SLOTS; slot++) + { + Point at = position + SLOT_AT[slot]; + const FamilyNode* node = chart.at(slot); + Point centre(SLOT_W / 2, 0); + + if (node) + { + // The one viewed sits in a box drawn on the background. + if (slot == FamilyChart::LEADER) + leader_plate[node->online].draw(at); + else if (slot != FamilyChart::OWNER) + other_plate[node->online].draw(at); + + int16_t lift = slot == FamilyChart::LEADER ? 1 : 0; + + names[slot].draw(at + centre + Point(0, 5 + lift) - raise); + levels[slot].draw(at + Point(LEVEL_X + LEVEL_W / 2, 20 + lift) - raise); + } + else + { + empties[slot].draw(at + centre + Point(0, 11) - raise); + } + + if (slot >= 7) + juniors[slot].draw(at + centre + Point(0, 58) - raise); + + // The glow's origin puts its corner three pixels out. + if (static_cast(slot) == selected || static_cast(slot) == hovered) + selected_glow.draw(at); + } + + title.draw(position + Point(TITLE_X + TITLE_W / 2, TITLE_Y) - raise); + total.draw(position + Point(TOTAL_X + TOTAL_W, TOTAL_Y) - raise); + seniors.draw(position + SENIORS_AT - raise); + + UIElement::draw(inter); + } + + int16_t UIFamilyTree::slot_at(Point local) const + { + for (size_t slot = 0; slot < FamilyChart::SLOTS; slot++) + { + Point at = SLOT_AT[slot]; + + if (inside(Rectangle(at.x(), at.x() + SLOT_W, at.y(), at.y() + SLOT_H), local)) + return static_cast(slot); + } + + return -1; + } + + bool UIFamilyTree::hoverable(int16_t slot) const + { + if (slot == FamilyChart::SENIOR) + return chart.at(FamilyChart::SENIOR) != nullptr; + + return slot == 5 || slot == 6; + } + + Cursor::State UIFamilyTree::send_cursor(bool clicked, Point cursorpos) + { + Cursor::State state = UIDragElement::send_cursor(clicked, cursorpos); + + if (dragged || !chart.mine) + { + hovered = -1; + clear_tooltip(); + + return state; + } + + Point local = cursorpos - position; + int16_t slot = slot_at(local); + + hovered = hoverable(slot) ? slot : -1; + + if (clicked && !is_holding_button()) + { + if (hoverable(slot)) + { + selected = chart.at(slot) ? slot : -1; + + if (!chart.at(slot)) + FamilyActions::prompt_junior(); + } + else if (slot >= 0) + { + selected = -1; + } + } + + std::string text; + + if (buttons[JUNIOR]->get_state() == Button::State::MOUSEOVER) + text = JUNIOR_HELP; + else if (buttons[BYE]->get_state() == Button::State::MOUSEOVER) + text = selected == FamilyChart::SENIOR ? SENIOR_BYE_HELP : JUNIOR_BYE_HELP; + else if (inside(TOTAL_TIP, local)) + text = TOTAL_HELP; + else if (slot >= 0 && chart.at(slot)) + text = member_tooltip(slot); + + if (text.empty()) + { + clear_tooltip(); + } + else + { + UI::get().show_text(Tooltip::Parent::FAMILYTREE, text); + tooltip = true; + } + + return state; + } + + std::string UIFamilyTree::member_tooltip(size_t slot) const + { + const FamilyNode& node = *chart.at(slot); + const FamilyNode* senior = node.senior >= 0 ? &chart.nodes[node.senior] : nullptr; + const FamilyNode* owner = chart.at(FamilyChart::OWNER); + std::string text; + + // The player's own box, or the leader's when that is them. + if (slot == FamilyChart::OWNER || (owner && node.id == owner->id)) + { + text += "Reps recorded to your Senior : " + number(node.senior_rep) + "\\n"; + + if (senior && senior->level < node.level) + text += PASSED_SENIOR; + + text += SEPARATOR; + + if (!chart.used.empty()) + { + const std::vector& privileges = Stage::get().get_player().get_family().get_privileges(); + + text += USED_TODAY; + + for (auto& [index, times] : chart.used) + { + std::string name = index >= 0 && index < static_cast(privileges.size()) + ? privileges[index].name : std::to_string(index); + + text += " " + name + " - " + std::to_string(times) + " times\\n"; + } + } + } + else + { + if (slot >= 5) + { + int32_t today = slot <= 6 ? node.senior_rep : node.today; + text += "Reps recorded today : " + number(today) + "\\n"; + + if (senior && senior->level < node.level) + text += PASSED_YOU; + + text += SEPARATOR; + } + + if (node.online) + { + std::string place = node.map_id < 0 ? CASH_SHOP + : NxHelper::Map::get_map_info_by_id(node.map_id).name; + + text += "Time elapsed since login : " + std::to_string(node.minutes / 60) + "hrs. " + + std::to_string(node.minutes % 60) + "min.\\n"; + text += "Current Location : " + place + "\\n"; + } + + text += "Current Reps : " + number(node.rep) + "\\nTotal Reps : " + number(node.total_rep) + "\\n"; + } + + // The last line's break would leave a blank one. + while (text.size() >= 2 && text.compare(text.size() - 2, 2, "\\n") == 0) + text.erase(text.size() - 2); + + return text; + } + + void UIFamilyTree::remove_cursor() + { + UIDragElement::remove_cursor(); + + hovered = -1; + clear_tooltip(); + } + + void UIFamilyTree::clear_tooltip() + { + if (!tooltip) + return; + + tooltip = false; + UI::get().clear_tooltip(Tooltip::Parent::FAMILYTREE); + } + + void UIFamilyTree::send_key(int32_t, bool pressed, bool escape) + { + if (pressed && escape) + { + clear_tooltip(); + deactivate(); + } + } + + Button::State UIFamilyTree::button_pressed(uint16_t buttonid) + { + if (buttonid == JUNIOR) + FamilyActions::prompt_junior(); + + return Button::State::NORMAL; + } + + UIElement::Type UIFamilyTree::get_type() const + { + return TYPE; + } +} diff --git a/HeavenClient/IO/UITypes/UIFamilyTree.h b/HeavenClient/IO/UITypes/UIFamilyTree.h new file mode 100644 index 00000000..26d4118e --- /dev/null +++ b/HeavenClient/IO/UITypes/UIFamilyTree.h @@ -0,0 +1,65 @@ +#pragma once + +#include "../UIDragElement.h" + +#include "../../Character/Family.h" +#include "../../Graphics/Text.h" +#include "../../Graphics/Texture.h" + +#include + +namespace ms +{ + // v83's Family Tree (UIWindow.img/FamilyTree, type 0x1c, report b044): + // eleven boxes around the character viewed, from the leader at the top + // to their juniors' juniors. It opens empty and fills when the server + // answers OPEN_FAMILY_PEDIGREE. Only the player's own tree answers the + // mouse. Esc closes it. + class UIFamilyTree : public UIDragElement + { + public: + static constexpr Type TYPE = UIElement::Type::FAMILYTREE; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + UIFamilyTree(); + + void draw(float inter) const override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + void remove_cursor() override; + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + // FAMILY_CHART_RESULT came, already arranged. + void set_chart(FamilyChart chart); + // A relative logged in or out (FAMILY_NOTIFY_LOGIN_OR_LOGOUT). + void set_online(const std::string& name, bool online); + bool is_mine() const; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + enum Buttons : uint16_t { JUNIOR, BYE }; + + void lay_out(); + int16_t slot_at(Point local) const; + bool hoverable(int16_t slot) const; + std::string member_tooltip(size_t slot) const; + void clear_tooltip(); + + FamilyChart chart; + + Texture background, selected_glow; + std::array leader_plate, other_plate; + + Text title, total, seniors; + std::array names, levels, empties, juniors; + + int16_t selected = -1; + int16_t hovered = -1; + bool tooltip = false; + }; +} diff --git a/HeavenClient/IO/UITypes/UIInviteCard.cpp b/HeavenClient/IO/UITypes/UIInviteCard.cpp index b038bf66..3aa58ea4 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.cpp +++ b/HeavenClient/IO/UITypes/UIInviteCard.cpp @@ -50,7 +50,9 @@ namespace ms { "icon2", "Trade request", Point(24, 2) }, { "icon5", "Party invite", Point(26, 4) }, { "icon5", "Guild invite", Point(26, 4), true }, - { "icon5", "Guild Alliance Invitation.", Point(26, 4), true, true } + { "icon5", "Guild Alliance Invitation.", Point(26, 4), true, true }, + // v83 writes it as the alliance's (FUN_00524298). + { "icon8", "It's a Family Invitation.", Point(26, 4), true } }; // v83 writes the arrivals at (25, 7), in Arial 11. diff --git a/HeavenClient/IO/UITypes/UIInviteCard.h b/HeavenClient/IO/UITypes/UIInviteCard.h index 4f2baefd..bff64259 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.h +++ b/HeavenClient/IO/UITypes/UIInviteCard.h @@ -16,7 +16,8 @@ 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, a party invite or a guild invite, each "from ''", - // or an alliance's, "''s Guild Alliance Invitation.". O accepts and X + // or an alliance's, "''s Guild Alliance Invitation.", or a + // senior's, "It's a Family Invitation.". 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. Duey's say a parcel @@ -30,8 +31,8 @@ namespace ms static constexpr bool FOCUSED = false; static constexpr bool TOGGLED = false; - // v83's notice types 0, 1, 2, 5, 8, 6, 9 and 10. - enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, GUILD, ALLIANCE, ARRIVAL, PARCEL, NUM_KINDS }; + // v83's notice types 0, 1, 2, 5, 8, 6, 0xc, 9 and 10. + enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, GUILD, ALLIANCE, FAMILY, ARRIVAL, PARCEL, NUM_KINDS }; UIInviteCard(); diff --git a/HeavenClient/IO/UITypes/UIKeyConfig.cpp b/HeavenClient/IO/UITypes/UIKeyConfig.cpp index 0191b36f..69557298 100644 --- a/HeavenClient/IO/UITypes/UIKeyConfig.cpp +++ b/HeavenClient/IO/UITypes/UIKeyConfig.cpp @@ -138,7 +138,6 @@ namespace ms } // The functions the window offers, by the index of their v83 icon. - // Family has no counterpart here. struct Function { KeyAction::Id action; @@ -172,6 +171,7 @@ namespace ms { KeyAction::Id::CASHSHOP, 23 }, { KeyAction::Id::ALLIANCECHAT, 24 }, { KeyAction::Id::PARTYSEARCH, 25 }, + { KeyAction::Id::FAMILY, 26 }, { KeyAction::Id::MEDALS, 27 }, { KeyAction::Id::PICKUP, 50 }, { KeyAction::Id::SIT, 51 }, @@ -228,6 +228,7 @@ namespace ms { KeyConfig::Key::LEFT_BRACKET, { KeyType::Id::MENU, KeyAction::Id::MENU } }, { KeyConfig::Key::RIGHT_BRACKET, { KeyType::Id::MENU, KeyAction::Id::QUICKSLOTS } }, { KeyConfig::Key::G, { KeyType::Id::MENU, KeyAction::Id::GUILD } }, + { KeyConfig::Key::F, { KeyType::Id::MENU, KeyAction::Id::FAMILY } }, { KeyConfig::Key::P, { KeyType::Id::MENU, KeyAction::Id::PARTY } }, { KeyConfig::Key::O, { KeyType::Id::MENU, KeyAction::Id::PARTYSEARCH } }, { KeyConfig::Key::L, { KeyType::Id::MENU, KeyAction::Id::NOTIFIER } }, diff --git a/HeavenClient/IO/UITypes/UIStatusBar.cpp b/HeavenClient/IO/UITypes/UIStatusBar.cpp index a65f8cd5..c339e2d6 100644 --- a/HeavenClient/IO/UITypes/UIStatusBar.cpp +++ b/HeavenClient/IO/UITypes/UIStatusBar.cpp @@ -85,6 +85,7 @@ namespace ms { KeyAction::Id::MONSTERBOOK, 22 }, { KeyAction::Id::CASHSHOP, 23 }, { KeyAction::Id::ALLIANCECHAT, 24 }, + { KeyAction::Id::FAMILY, 26 }, { KeyAction::Id::MEDALS, 27 }, { KeyAction::Id::PICKUP, 50 }, { KeyAction::Id::SIT, 51 }, diff --git a/HeavenClient/Net/Handlers/FamilyHandlers.cpp b/HeavenClient/Net/Handlers/FamilyHandlers.cpp new file mode 100644 index 00000000..b8869c53 --- /dev/null +++ b/HeavenClient/Net/Handlers/FamilyHandlers.cpp @@ -0,0 +1,214 @@ +#include "FamilyHandlers.h" + +#include "Helpers/FamilyParser.h" + +#include "../Packets/FamilyPackets.h" + +#include "../../Configuration.h" +#include "../../Gameplay/Stage.h" +#include "../../IO/FamilyActions.h" +#include "../../IO/UI.h" +#include "../../IO/UITypes/UIFamily.h" +#include "../../IO/UITypes/UIFamilyTree.h" +#include "../../IO/UITypes/UIInviteCard.h" +#include "../../IO/UITypes/UINotice.h" + +#include + +namespace ms +{ + namespace + { + // FAMILY_RESULT's codes (0xa345fb, report b039 1.3) with their + // StringPool texts. 0x4b and 0x4c go to the chat log; the others + // are OK dialogs. Code 1, the end of a relationship the player + // severed, comes with separation. + const char* result_text(int32_t code) + { + switch (code) + { + case 0x40: return "You cannot add this character as a Junior."; + case 0x41: return "The name you entered is incorrect or he/she is currently not logged in."; + case 0x42: return "You belong to the same family."; + case 0x43: return "You do not belong to the same family."; + case 0x45: return "The character you wish to add as\\na Junior must be in the same map."; + case 0x46: return "This character is already a Junior of another character."; + case 0x47: return "The Junior you wish to add\\nmust be at a lower rank."; + case 0x48: return "The gap between you and your\\njunior must be within 20 levels."; + case 0x49: return "Another character has requested to add this character.\\nPlease try again later."; + case 0x4a: return "Another character has requested a summon.\\nPlease try again later."; + case 0x4b: return "The summons has failed. Your current location or state does not allow a summons."; + case 0x4c: return "The family cannot extend more than 1000 generations from above and below."; + case 0x4d: return "The Junior you wish to add\\nmust be over Level 10."; + case 0x4e: return "You cannot add a Junior that has requested to change worlds."; + case 0x4f: return "You cannot add a Junior since you've requested to change worlds."; + case 0x50: return "Separation is not possible due to insufficient Mesos. You will need %d Mesos to separate with a Senior."; + case 0x51: return "Separation is not possible due to insufficient Mesos. You will need %d Mesos to separate with a Junior."; + case 0x52: return "The Entitlement does not apply because your level does not match the corresponding area."; + default: return nullptr; + } + } + + std::string with_number(std::string text, int32_t value) + { + size_t at = text.find("%d"); + + if (at != std::string::npos) + text.replace(at, 2, std::to_string(value)); + + return text; + } + + void ok(const std::string& text) + { + UI::get().emplace(text, [](bool) {}); + } + + void refresh_window() + { + if (auto window = UI::get().get_element()) + window->refresh(); + } + + // The tree, when it is open and the player's own. + Optional own_tree() + { + auto tree = UI::get().get_element(); + + if (tree && tree->is_active() && tree->is_mine()) + return tree; + + return {}; + } + } + + void FamilyChartHandler::handle(InPacket& recv) const + { + auto tree = UI::get().get_element(); + + if (!tree || !tree->is_active()) + return; + + const Player& player = Stage::get().get_player(); + + FamilyNode self; + self.id = player.get_oid(); + self.job = player.get_stats().get_job().get_id(); + self.level = static_cast(player.get_level()); + self.name = player.get_stats().get_name(); + + FamilyChart chart = FamilyParser::parse_chart(recv, self.id); + chart.arrange(self); + + tree->set_chart(std::move(chart)); + } + + void FamilyInfoHandler::handle(InPacket& recv) const + { + Player& player = Stage::get().get_player(); + player.get_family().set_info(FamilyParser::parse_info(recv)); + + // The own tree asks again for what changed (0xa344f8). + if (own_tree()) + OpenFamilyPedigreePacket(player.get_stats().get_name()).dispatch(); + + refresh_window(); + } + + void FamilyResultHandler::handle(InPacket& recv) const + { + int32_t code = recv.read_int(); + int32_t arg = recv.read_int(); + + const char* text = result_text(code); + + if (!text) + return; + + if (code == 0x4b || code == 0x4c) + FamilyActions::chat_line(text); + else + ok(with_number(text, arg)); + } + + // A card, unless one is up already or the Game Option blocks it, when + // the client says no itself (report b039 1.4). Its X says no; letting it fade + // says nothing. O asks once more before saying yes. + void FamilyJoinRequestHandler::handle(InPacket& recv) const + { + int32_t inviter = recv.read_int(); + std::string name = recv.read_string(); + + auto cards = UI::get().get_element(); + + if (!cards || cards->showing(UIInviteCard::FAMILY) || !Setting::get().load()) + return AcceptFamilyPacket(inviter, name, false).dispatch(); + + cards->add(UIInviteCard::FAMILY, name, [inviter, name](bool yes) + { + if (!yes) + return AcceptFamilyPacket(inviter, name, false).dispatch(); + + // StringPool 0x1205. + std::string question = name + " would like to\\nbe your Senior. Would you like to be\\n" + name + "'s Junior?"; + + UI::get().emplace(question, [inviter, name](bool accept) + { + AcceptFamilyPacket(inviter, name, accept).dispatch(); + }); + }); + } + + void FamilyJoinRequestResultHandler::handle(InPacket& recv) const + { + bool accepted = recv.read_bool(); + std::string junior = recv.read_string(); + + // StringPool 0x1207 and 0x1206. + if (accepted) + FamilyActions::chat_line("You have added " + junior + " as a Junior. Please do your best to guide the Junior in his/her game play."); + else + ok(junior + " has declined your Junior request."); + } + + void FamilyJoinAcceptedHandler::handle(InPacket& recv) const + { + std::string senior = recv.read_string(); + + // StringPool 0x1208. + FamilyActions::chat_line(senior + " has become your Senior. He/she will guide you in your game play."); + } + + void FamilyPrivilegeListHandler::handle(InPacket& recv) const + { + Stage::get().get_player().get_family().set_privileges(FamilyParser::parse_privileges(recv)); + + refresh_window(); + } + + // The numbers in the window wait for the INFO that follows. + void FamilyRepGainHandler::handle(InPacket& recv) const + { + int32_t amount = recv.read_int(); + std::string name = recv.read_string(); + + // StringPool 0x120b and 0x120c. + if (amount >= 0) + FamilyActions::chat_line("Your Rep has increased (" + name + ", +" + std::to_string(amount) + ")"); + else + FamilyActions::chat_line("You have used " + std::to_string(std::abs(amount)) + " Rep Points."); + } + + void FamilyNotifyLoginOrLogoutHandler::handle(InPacket& recv) const + { + bool online = recv.read_bool(); + std::string name = recv.read_string(); + + if (online) + if (auto cards = UI::get().get_element()) + cards->notify(name, false); + + if (auto tree = own_tree()) + tree->set_online(name, online); + } +} diff --git a/HeavenClient/Net/Handlers/FamilyHandlers.h b/HeavenClient/Net/Handlers/FamilyHandlers.h new file mode 100644 index 00000000..a1826c41 --- /dev/null +++ b/HeavenClient/Net/Handlers/FamilyHandlers.h @@ -0,0 +1,79 @@ +#pragma once + +#include "../PacketHandler.h" + +namespace ms +{ + // The Family Tree of the character asked for; dropped unless the tree + // is open + // Opcode: FAMILY_CHART_RESULT(94) + class FamilyChartHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // The player's family, juniors, Rep and entitlements used today + // Opcode: FAMILY_INFO_RESULT(95) + class FamilyInfoHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // A refusal or the end of a relationship + // Opcode: FAMILY_RESULT(96) + class FamilyResultHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // Someone asks the player to be their junior + // Opcode: FAMILY_JOIN_REQUEST(97) + class FamilyJoinRequestHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // The one asked said yes or no + // Opcode: FAMILY_JOIN_REQUEST_RESULT(98) + class FamilyJoinRequestResultHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // The player has a senior now + // Opcode: FAMILY_JOIN_ACCEPTED(99) + class FamilyJoinAcceptedHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // The entitlements the Family window offers + // Opcode: FAMILY_PRIVILEGE_LIST(100) + class FamilyPrivilegeListHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // Rep gained or spent + // Opcode: FAMILY_REP_GAIN(101) + class FamilyRepGainHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // A relative logged in or out + // Opcode: FAMILY_NOTIFY_LOGIN_OR_LOGOUT(102) + class FamilyNotifyLoginOrLogoutHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; +} diff --git a/HeavenClient/Net/Handlers/Helpers/FamilyParser.cpp b/HeavenClient/Net/Handlers/Helpers/FamilyParser.cpp new file mode 100644 index 00000000..4cb92446 --- /dev/null +++ b/HeavenClient/Net/Handlers/Helpers/FamilyParser.cpp @@ -0,0 +1,93 @@ +#include "FamilyParser.h" + +namespace ms +{ + namespace FamilyParser + { + namespace + { + std::map parse_counts(InPacket& recv) + { + std::map counts; + int32_t count = recv.read_int(); + + for (int32_t i = 0; i < count; i++) + { + int32_t key = recv.read_int(); + counts[key] = recv.read_int(); + } + + return counts; + } + } + + FamilyInfo parse_info(InPacket& recv) + { + FamilyInfo info; + info.rep = recv.read_int(); + info.total_rep = recv.read_int(); + info.today = recv.read_int(); + info.juniors = recv.read_short(); + info.max_juniors = recv.read_short(); + recv.skip_short(); + info.leader_id = recv.read_int(); + info.name = recv.read_string(); + info.precepts = recv.read_string(); + info.used = parse_counts(recv); + + return info; + } + + std::vector parse_privileges(InPacket& recv) + { + std::vector privileges; + int32_t count = recv.read_int(); + + for (int32_t i = 0; i < count; i++) + { + FamilyPrivilege privilege; + privilege.type = recv.read_byte(); + privilege.cost = recv.read_int(); + privilege.limit = recv.read_int(); + privilege.name = recv.read_string(); + privilege.description = recv.read_string(); + privileges.push_back(privilege); + } + + return privileges; + } + + FamilyChart parse_chart(InPacket& recv, int32_t self) + { + FamilyChart chart; + chart.viewed = recv.read_int(); + chart.mine = chart.viewed == self; + + int32_t count = recv.read_int(); + + for (int32_t i = 0; i < count; i++) + { + FamilyNode node; + node.id = recv.read_int(); + node.senior_id = recv.read_int(); + node.job = recv.read_short(); + node.level = recv.read_byte(); + node.online = recv.read_bool(); + node.rep = recv.read_int(); + node.total_rep = recv.read_int(); + node.senior_rep = recv.read_int(); + node.today = recv.read_int(); + node.map_id = recv.read_int(); + node.minutes = recv.read_int(); + node.name = recv.read_string(); + chart.nodes.push_back(node); + } + + chart.counts = parse_counts(recv); + chart.used = parse_counts(recv); + chart.max_juniors = recv.read_short(); + + return chart; + } + } +} diff --git a/HeavenClient/Net/Handlers/Helpers/FamilyParser.h b/HeavenClient/Net/Handlers/Helpers/FamilyParser.h new file mode 100644 index 00000000..f9cef7c8 --- /dev/null +++ b/HeavenClient/Net/Handlers/Helpers/FamilyParser.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../../InPacket.h" + +#include "../../../Character/Family.h" + +namespace ms +{ + namespace FamilyParser + { + // FAMILY_INFO_RESULT (0xa344f8). + FamilyInfo parse_info(InPacket& recv); + + // FAMILY_PRIVILEGE_LIST. + std::vector parse_privileges(InPacket& recv); + + // FAMILY_CHART_RESULT (0x80a5f7), not yet arranged into boxes. + FamilyChart parse_chart(InPacket& recv, int32_t self); + } +} diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 2e93656f..3799bac3 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -149,6 +149,12 @@ namespace ms USE_DOOR = 133, ALLIANCE_OPERATION = 143, DENY_ALLIANCE_REQUEST = 144, + OPEN_FAMILY_PEDIGREE = 145, + OPEN_FAMILY = 146, + ADD_FAMILY = 147, + ACCEPT_FAMILY = 150, + USE_FAMILY = 151, + CHANGE_FAMILY_MESSAGE = 152, GUILD_BBS = 155, USE_SOLOMON_ITEM = 157, USE_GACHA_EXP = 158, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index c0b8f305..0e0aaef4 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -23,6 +23,7 @@ #include "Handlers/MapObjectHandlers.h" #include "Handlers/CashShopHandlers.h" #include "Handlers/CommonHandlers.h" +#include "Handlers/FamilyHandlers.h" #include "Handlers/InventoryHandlers.h" #include "Handlers/LoginHandlers.h" #include "Handlers/MapObjectHandlers.h" @@ -239,6 +240,17 @@ namespace ms GUILD_NAME_CHANGED = 202, GUILD_MARK_CHANGED = 203, + /// Family + FAMILY_CHART_RESULT = 94, + FAMILY_INFO_RESULT = 95, + FAMILY_RESULT = 96, + FAMILY_JOIN_REQUEST = 97, + FAMILY_JOIN_REQUEST_RESULT = 98, + FAMILY_JOIN_ACCEPTED = 99, + FAMILY_PRIVILEGE_LIST = 100, + FAMILY_REP_GAIN = 101, + FAMILY_NOTIFY_LOGIN_OR_LOGOUT = 102, + /// Cash Shop SET_CASH_SHOP = 127, QUERY_CASH_RESULT = 324, @@ -427,6 +439,17 @@ namespace ms emplace(); emplace(); + // Families + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + // Cash Shop emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/FamilyPackets.h b/HeavenClient/Net/Packets/FamilyPackets.h new file mode 100644 index 00000000..132e87ae --- /dev/null +++ b/HeavenClient/Net/Packets/FamilyPackets.h @@ -0,0 +1,74 @@ +#pragma once + +#include "../OutPacket.h" + +namespace ms +{ + // Asks for a Family Tree, the player's own or another's + // (OPEN_FAMILY_PEDIGREE). + class OpenFamilyPedigreePacket : public OutPacket + { + public: + OpenFamilyPedigreePacket(const std::string& name) : OutPacket(OutPacket::Opcode::OPEN_FAMILY_PEDIGREE) + { + write_string(name); + } + }; + + // The Family window opened (OPEN_FAMILY): the server answers with the + // player's numbers and the entitlements. + class OpenFamilyPacket : public OutPacket + { + public: + OpenFamilyPacket() : OutPacket(OutPacket::Opcode::OPEN_FAMILY) {} + }; + + // Asks a character on the player's map to be their junior + // (ADD_FAMILY). + class AddFamilyPacket : public OutPacket + { + public: + AddFamilyPacket(const std::string& name) : OutPacket(OutPacket::Opcode::ADD_FAMILY) + { + write_string(name); + } + }; + + // The answer to a senior's invitation (ACCEPT_FAMILY). + class AcceptFamilyPacket : public OutPacket + { + public: + AcceptFamilyPacket(int32_t inviter_id, const std::string& inviter, bool accept) : OutPacket(OutPacket::Opcode::ACCEPT_FAMILY) + { + write_int(inviter_id); + write_string(inviter); + write_byte(accept); + } + }; + + // Uses the entitlement at `index` of the list, on a member for types 0 + // and 1 (USE_FAMILY). + class UseFamilyPacket : public OutPacket + { + public: + UseFamilyPacket(int32_t index) : OutPacket(OutPacket::Opcode::USE_FAMILY) + { + write_int(index); + } + + UseFamilyPacket(int32_t index, const std::string& target) : UseFamilyPacket(index) + { + write_string(target); + } + }; + + // New precepts from the leader (CHANGE_FAMILY_MESSAGE). + class ChangeFamilyMessagePacket : public OutPacket + { + public: + ChangeFamilyMessagePacket(const std::string& precepts) : OutPacket(OutPacket::Opcode::CHANGE_FAMILY_MESSAGE) + { + write_string(precepts); + } + }; +} diff --git a/HeavenClient/tests/family.cpp b/HeavenClient/tests/family.cpp new file mode 100644 index 00000000..7d070710 --- /dev/null +++ b/HeavenClient/tests/family.cpp @@ -0,0 +1,235 @@ +#include "../Character/Family.h" +#include "../Net/Handlers/Helpers/FamilyParser.h" +#include +#include +#include +#include +#include + +using namespace ms; + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +struct Writer +{ + std::vector bytes; + + template + Writer& put(T value) + { + int8_t raw[sizeof(T)]; + std::memcpy(raw, &value, sizeof(T)); + bytes.insert(bytes.end(), raw, raw + sizeof(T)); + return *this; + } + + Writer& string(const std::string& text) + { + put(static_cast(text.size())); + bytes.insert(bytes.end(), text.begin(), text.end()); + return *this; + } + + // One chart entry. + Writer& member(int32_t id, int32_t senior, uint8_t level, const std::string& name, bool online = true) + { + put(id).put(senior).put(100).put(level).put(online); + put(5).put(50).put(2).put(1); + put(104000000).put(75); + return string(name); + } + + Writer& counts(std::vector> entries) + { + put(static_cast(entries.size())); + + for (auto& entry : entries) + put(entry.first).put(entry.second); + + return *this; + } + + InPacket packet() const + { + return InPacket(bytes.data(), bytes.size()); + } +}; + +struct Entry +{ + int32_t id; + int32_t senior; + uint8_t level; + const char* name; +}; + +FamilyChart chart(int32_t viewed, int32_t self, std::vector entries, uint16_t max_juniors = 2) +{ + Writer w; + w.put(viewed).put(static_cast(entries.size())); + + for (auto& entry : entries) + w.member(entry.id, entry.senior, entry.level, entry.name); + + w.counts({ { -1, static_cast(entries.size()) }, { 0, 0 } }).counts({}).put(max_juniors); + + InPacket recv = w.packet(); + FamilyChart result = FamilyParser::parse_chart(recv, self); + require(!recv.available(), "the whole chart is read"); + + FamilyNode local; + local.id = self; + local.level = 70; + local.job = 110; + local.name = "Me"; + result.arrange(local); + + return result; +} + +std::string name_at(const FamilyChart& c, size_t slot) +{ + const FamilyNode* node = c.at(slot); + + return node ? node->name : "-"; +} + +void reads_info_and_privileges() +{ + Writer w; + w.put(30).put(120).put(-4); + w.put(1).put(2).put(0).put(7); + w.string("Maple").string("Be kind").counts({ { 1, 2 } }); + + InPacket recv = w.packet(); + FamilyInfo info = FamilyParser::parse_info(recv); + require(!recv.available(), "the whole info is read"); + require(info.rep == 30 && info.total_rep == 120 && info.today == -4, "info reps"); + require(info.juniors == 1 && info.max_juniors == 2 && info.leader_id == 7, "info juniors and leader"); + require(info.name == "Maple" && info.precepts == "Be kind", "info texts"); + require(info.used_today(1) == 2 && info.used_today(0) == 0, "info uses"); + + Writer p; + p.put(2); + p.put(1).put(10).put(3).string("Summon").string("Brings one"); + p.put(3).put(40).put(1).string("EXP").string("More EXP"); + + InPacket precv = p.packet(); + auto privileges = FamilyParser::parse_privileges(precv); + require(!precv.available(), "the whole list is read"); + require(privileges.size() == 2 && privileges[0].name == "Summon" && privileges[1].cost == 40, "privileges"); + require(privileges[0].needs_target() && !privileges[1].needs_target(), "types 0 and 1 need a name"); + + Family family; + require(!family.loaded() && !family.exists(), "nothing before INFO"); + family.set_info(info); + family.set_privileges(privileges); + require(family.loaded() && family.exists(), "loaded by INFO"); + require(family.is_leader(7) && !family.is_leader(8), "only the leader edits the precepts"); + require(family.can_use(0), "enough Rep and uses left"); + require(!family.can_use(1), "not enough Rep"); + + info.used[0] = 3; + family.set_info(info); + require(!family.can_use(0), "no uses left today"); + require(!family.can_use(2), "no such privilege"); + + info.name.clear(); + family.set_info(info); + require(family.loaded() && !family.exists(), "loaded outside a family"); + require(!family.can_use(0), "no entitlements outside a family"); +} + +void the_leader_heads_the_tree_and_comes_again_as_the_one_viewed() +{ + // What the server sends a leader with one junior. + FamilyChart own = chart(1, 1, { { 1, 0, 30, "A" }, { 1, 0, 30, "A" }, { 2, 1, 15, "B" } }); + require(own.mine, "the player's own tree"); + require(name_at(own, 0) == "A" && name_at(own, 3) == "A", "the leader at the top and as the owner"); + require(own.at(0) != own.at(3), "two nodes"); + require(name_at(own, 5) == "B" && name_at(own, 6) == "-", "the junior below"); + require(name_at(own, 2) == "-" && name_at(own, 1) == "-", "nobody above"); + require(!own.at(3)->online, "the owner is not marked online"); + require(own.can_add_junior(), "room for a second junior"); + + // The junior's tree: the leader is also the senior. + FamilyChart junior = chart(2, 2, { { 1, 0, 30, "A" }, { 1, 0, 30, "A" }, { 2, 1, 15, "B" } }); + require(name_at(junior, 0) == "A" && name_at(junior, 2) == "A" && name_at(junior, 3) == "B", "the junior's tree"); + require(name_at(junior, 5) == "-", "no juniors"); + + // Another's tree: no buttons. + FamilyChart theirs = chart(2, 1, { { 1, 0, 30, "A" }, { 1, 0, 30, "A" }, { 2, 1, 15, "B" } }); + require(!theirs.mine && !theirs.can_add_junior(), "not the player's tree"); +} + +void the_first_entry_is_nobodys_senior() +{ + // Without the leader repeated, the junior hangs from nothing. + FamilyChart c = chart(1, 1, { { 1, 0, 30, "A" }, { 2, 1, 15, "B" }, { 3, 1, 15, "C" } }); + require(name_at(c, 0) == "A", "the leader at the top"); + require(name_at(c, 3) == "Me", "the owner built from the player"); + require(name_at(c, 5) == "-", "nobody links to the first entry"); +} + +void two_or_fewer_entries_leave_the_top_empty() +{ + FamilyChart pair = chart(1, 1, { { 1, 0, 30, "A" }, { 1, 0, 30, "A" } }); + require(name_at(pair, 0) == "-" && name_at(pair, 3) == "A", "a lone leader"); + + FamilyChart none = chart(1, 1, {}); + require(name_at(none, 0) == "-" && name_at(none, 3) == "Me", "no family: the player alone"); + require(none.at(3)->online && none.at(3)->level == 70, "from the player's own stats"); + require(none.can_add_junior(), "may add a junior"); + + FamilyChart other = chart(9, 1, {}); + require(other.at(3) == nullptr, "nobody for another's empty tree"); +} + +void four_generations_around_the_one_viewed() +{ + FamilyChart c = chart(5, 5, { + { 1, 0, 120, "L" }, + { 3, 2, 100, "S2" }, + { 4, 3, 90, "S1" }, + { 5, 4, 70, "V" }, + { 6, 4, 75, "Sib" }, + { 7, 5, 50, "J1" }, + { 8, 5, 80, "J2" }, + { 9, 7, 30, "G1" }, + { 10, 7, 30, "G2" }, + { 11, 8, 30, "G3" }, + }); + + const char* expected[] = { "L", "S2", "S1", "V", "Sib", "J1", "J2", "G1", "G2", "G3", "-" }; + + for (size_t slot = 0; slot < FamilyChart::SLOTS; slot++) + require(name_at(c, slot) == expected[slot], "each box"); + + require(!c.can_add_junior(), "two juniors already"); + require(c.passed_senior(6), "J2 passed V"); + require(!c.passed_senior(5) && !c.passed_senior(4), "only juniors who passed"); + require(c.count(-1) == 10 && c.count(42) == 0, "counts"); +} + +int main() +{ + try + { + reads_info_and_privileges(); + the_leader_heads_the_tree_and_comes_again_as_the_one_viewed(); + the_first_entry_is_nobodys_senior(); + two_or_fewer_entries_leave_the_top_empty(); + four_generations_around_the_one_viewed(); + } + catch (const std::exception& error) + { + std::cerr << "family: " << error.what() << "\n"; + return 1; + } + + std::cout << "family: all passed\n"; + return 0; +} diff --git a/agent_docs/client.md b/agent_docs/client.md index a8c16120..657ab354 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -342,7 +342,8 @@ from `UI.nx`. Moved so far: COLLECTION lengthen it to `backgrnd5`, `backgrnd2`, and `backgrnd7`, and ITEM and WISHLIST open `backgrnd4` and `backgrnd3` beside it; all are empty, as RustMS has no mounts or monster book, and the pet and wish list pages are - not read. Family and excluded items are disabled. REQ PARTY, greyed in the + not read. Excluded items are disabled. Family opens the player's own Family + window or someone else's Family Tree (`agent_docs/families.md`). REQ PARTY, greyed in the player's own window, invites the character when the player leads a party, and otherwise does nothing (v83 FUN_008ff5b0). TRADE, also greyed in the player's own window, invites the character to a trade. The fame buttons send @@ -358,18 +359,22 @@ from `UI.nx`. Moved so far: `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 + party), GUILD INVITE (guild masters and Jr. Masters), ALERT GM, and FAMILY (once + the family is known and has room for a junior); 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 puts the chat into whisper mode for them, 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, and ALERT GM shows v83's notice that reports are down. + v83's guild checks, and ALERT GM shows v83's notice that reports are down, and FAMILY sends + ADD_FAMILY. Approximations: v83 also hits the character's second position and a - morph's own box, and it offers Add Junior to family members, which HC - doesn't have. The right button acts when it comes up, everywhere, as in + morph's own box. The right button acts when it comes up, everywhere, as in v83. +- **Family** (`UIFamily`, `UIFamilyTree`, `UIWindow.img/Family` and + `FamilyTree`): the Family window from the Family key, and the Family Tree + from it or from someone's info. See `agent_docs/families.md`. - **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 diff --git a/agent_docs/families.md b/agent_docs/families.md index 5b8ec75b..d274298b 100644 --- a/agent_docs/families.md +++ b/agent_docs/families.md @@ -68,10 +68,13 @@ packets into `FamilyRequest`s, which the client actor (`DirectoryMessage::FamilyChanged`). - **`OPEN_FAMILY_PEDIGREE` (name):** the server sends `FAMILY_CHART_RESULT` for the named character (`db::family::chart`). - - The chart lists the leader, the two seniors above the character, the - character, their sibling, their juniors, and their juniors' juniors, - each parent before its children. These are the 11 slots of the window. - - A senior's senior whose own senior isn't shown hangs from the leader. + - The chart lists the leader, then the two seniors above the character, + the character, their sibling, their juniors, and their juniors' + juniors, each parent before its children. These are the 11 slots of + the window. + - The client takes the first entry only as the top of the tree and never + links anyone to it (report b044), so the leader comes again in the + chain when it is one of the seniors or the character. - The directory marks who is online (a channel change counts) and fills in their current level, job and map. - Head counts: @@ -100,4 +103,37 @@ These are not implemented yet: ## Client -HeavenClient has no family windows yet. +HeavenClient follows reports b042 (Family window), b044 (Family Tree) and +b045 (how they open). + +- **State:** `Character/Family.h` keeps the player's INFO and the privilege + list on `Player`. `FamilyChart::arrange` puts a chart's members into the + tree's 11 boxes as the v83 decoder does. `tests/family.cpp` covers the + parser and the arrangement. +- **Packets:** `Net/Handlers/FamilyHandlers.cpp` handles receive opcodes + 94-102. `Net/Packets/FamilyPackets.h` builds the sends; separation (0x94, + 0x95) and the summon answer (0x99) are not built yet. +- **Family window (`UIFamily`):** the Family key (F by default) and BtFamily + on the player's own info open it, and each opening sends `OPEN_FAMILY`. + It shows the family's name, the scrolling precepts, juniors, Rep and the + entitlements one at a time, with their tooltips. BtJuniorEntry asks for + a name, BtTree opens the player's own tree, the precepts button (leader + only) sends `CHANGE_FAMILY_MESSAGE` and BtSpecial sends `USE_FAMILY`. + OK closes it, and so does Esc when it is the topmost window. +- **Family Tree (`UIFamilyTree`):** BtTree, and BtFamily on someone else's + info, open it and send `OPEN_FAMILY_PEDIGREE`. It always opens at the same + place and fills when the chart arrives. Only the player's own tree + answers the mouse: the senior's and juniors' boxes light up, an empty + junior box asks for a name, and each member has a tooltip. Esc closes it. + Texts are centred in their boxes as v83 does it, halving the room left as + unsigned, so one wider than its box is never seen: the empty boxes' + "(You do not have family members.)" shows nothing. + BtBye stays disabled until separation exists. +- **Adding a junior:** the name prompt, or the avatar menu's Family row + (shown once INFO says there is room), sends `ADD_FAMILY`. +- **Being asked:** `FAMILY_JOIN_REQUEST` shows a Family card, unless one is + up or the Game Option blocks it, when the client declines by itself. The + card's X declines, and fading sends nothing. O asks once more with a + Yes/No before answering. +- **Login notices:** a relative logging in shows a card, and the open tree + updates who is online. diff --git a/db/src/family.rs b/db/src/family.rs index 7bacf677..3b75df6f 100644 --- a/db/src/family.rs +++ b/db/src/family.rs @@ -144,8 +144,10 @@ pub fn chart(viewed: i32) -> crate::Result { return Ok(chart); } let leader = seniors.last().copied().unwrap_or(viewed); - let mut ids = vec![leader]; - ids.extend(seniors.iter().take(2).rev()); + // The client only shows the first entry, the leader, as the top of the + // tree and links the others by their seniors, so the leader comes again + // where it falls in that chain. + let mut ids: Vec = seniors.iter().take(2).rev().copied().collect(); ids.push(viewed); if let Some(&senior) = seniors.first() { ids.extend(self::juniors(conn, senior)?); @@ -156,6 +158,7 @@ pub fn chart(viewed: i32) -> crate::Result { } let mut seen = std::collections::HashSet::new(); ids.retain(|id| seen.insert(*id)); + ids.insert(0, leader); let rows = characters::table .inner_join(family_members::table) @@ -173,11 +176,10 @@ pub fn chart(viewed: i32) -> crate::Result { characters::name, )) .load::<(i32, Option, i16, i16, i32, i32, i32, i32, i32, String)>(conn)?; - let mut rows: std::collections::HashMap<_, _> = - rows.into_iter().map(|row| (row.0, row)).collect(); + let rows: std::collections::HashMap<_, _> = rows.into_iter().map(|row| (row.0, row)).collect(); chart.members = ids .iter() - .filter_map(|id| rows.remove(id)) + .filter_map(|id| rows.get(id).cloned()) .map(|row| ChartMember { id: row.0, senior_id: row.1.unwrap_or(0), @@ -192,14 +194,6 @@ pub fn chart(viewed: i32) -> crate::Result { name: row.9, }) .collect(); - // The senior's senior hangs from the leader when generations between - // aren't shown. - let shown: Vec = chart.members.iter().map(|member| member.id).collect(); - for member in chart.members.iter_mut().skip(1) { - if !shown.contains(&member.senior_id) { - member.senior_id = leader; - } - } let family = descendants(conn, leader)?.0.len(); chart.counts = vec![(-1, family as i32), (0, seniors.len() as i32)]; diff --git a/game/src/keymap.rs b/game/src/keymap.rs index 416fe551..3256dac1 100644 --- a/game/src/keymap.rs +++ b/game/src/keymap.rs @@ -12,8 +12,7 @@ pub const KEY_COUNT: i16 = 90; /// Default bindings for new characters: (key, kind, action). Keys are scan /// codes and actions HeavenClient's (`IO/KeyAction.h`); the layout is v83's. -/// v83 also binds F to family, which the client has no action for. -const DEFAULT_BINDINGS: [(i16, u8, i32); 40] = [ +const DEFAULT_BINDINGS: [(i16, u8, i32); 41] = [ (59, 6, 100), (60, 6, 101), (61, 6, 102), @@ -50,6 +49,8 @@ const DEFAULT_BINDINGS: [(i16, u8, i32); 40] = [ (25, 4, 20), // O opens the Party Search window (HeavenClient's PARTYSEARCH 26). (24, 4, 26), + // F opens the Family window (HeavenClient's FAMILY 27, v83's 26). + (33, 4, 27), (38, 4, 21), (41, 4, 24), (39, 4, 28), @@ -107,6 +108,6 @@ mod tests { assert!(keymap.get(59).is_some()); keymap.bind(59, KeyBinding { kind: 0, action: 0 }); assert!(keymap.get(59).is_none()); - assert_eq!(keymap.iter().count(), 39); + assert_eq!(keymap.iter().count(), 40); } } diff --git a/integration-harness/tests/families.rs b/integration-harness/tests/families.rs index 26e539b9..6f5737b5 100644 --- a/integration-harness/tests/families.rs +++ b/integration-harness/tests/families.rs @@ -180,8 +180,13 @@ async fn a_senior_takes_a_junior_on_their_map_who_says_yes() { ) .await; let mut chart = a_id.to_le_bytes().to_vec(); - chart.extend(2i32.to_le_bytes()); - for (id, senior, level, name) in [(a_id, 0, 30u8, &an), (b_id, a_id, 15, &bn)] { + chart.extend(3i32.to_le_bytes()); + // The leader heads the tree and comes again as the one viewed. + for (id, senior, level, name) in [ + (a_id, 0, 30u8, &an), + (a_id, 0, 30, &an), + (b_id, a_id, 15, &bn), + ] { chart.extend(id.to_le_bytes()); chart.extend(senior.to_le_bytes()); chart.extend(0i16.to_le_bytes()); diff --git a/net/src/packet/build/world/keymap.rs b/net/src/packet/build/world/keymap.rs index ec84fc4a..921f91cc 100644 --- a/net/src/packet/build/world/keymap.rs +++ b/net/src/packet/build/world/keymap.rs @@ -133,6 +133,7 @@ mod tests { (34, 4, 18), (25, 4, 20), (24, 4, 26), + (33, 4, 27), (38, 4, 21), (41, 4, 24), (39, 4, 28),