diff --git a/HeavenClient/Audio/Audio.cpp b/HeavenClient/Audio/Audio.cpp index 9d70498af..c423ee83b 100644 --- a/HeavenClient/Audio/Audio.cpp +++ b/HeavenClient/Audio/Audio.cpp @@ -175,6 +175,8 @@ namespace ms add_sound(Sound::Name::JOBCHANGED, gamesrc["JobChanged"]); add_sound(Sound::Name::QUESTCLEAR, gamesrc["QuestClear"]); add_sound(Sound::Name::TOMBSTONE, gamesrc["Tombstone"]); + add_sound(Sound::Name::ENCHANTSUCCESS, gamesrc["EnchantSuccess"]); + add_sound(Sound::Name::ENCHANTFAILURE, gamesrc["EnchantFailure"]); nl::node itemsrc = nl::nx::sound["Item.img"]; diff --git a/HeavenClient/Audio/Audio.h b/HeavenClient/Audio/Audio.h index 0d41ec5ba..ff02716ce 100644 --- a/HeavenClient/Audio/Audio.h +++ b/HeavenClient/Audio/Audio.h @@ -89,6 +89,8 @@ namespace ms JOBCHANGED, QUESTCLEAR, TOMBSTONE, + ENCHANTSUCCESS, + ENCHANTFAILURE, LENGTH }; diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 20d7fdc20..b784ded0d 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -157,6 +157,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME skill_recommendation COMMAND skill_recommendation_tests) add_executable(emote_cooldown_tests tests/emote_cooldown.cpp) add_test(NAME emote_cooldown COMMAND emote_cooldown_tests) + add_executable(fame_cooldown_tests tests/fame_cooldown.cpp) + add_test(NAME fame_cooldown COMMAND fame_cooldown_tests) add_executable(chat_flood_guard_tests tests/chat_flood_guard.cpp) add_test(NAME chat_flood_guard COMMAND chat_flood_guard_tests) add_executable(char_click_tests tests/char_click.cpp) diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index dc4834256..ca5134006 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -297,6 +297,12 @@ namespace ms effects.add(effect); } + void Char::show_skill_book(bool success) + { + effects.add(skill_book[success][0], DrawArgument(), 3); + effects.add(skill_book[success][1], DrawArgument(), -1); + } + void Char::show_iron_body() { ironbody.set_for(500); @@ -612,6 +618,10 @@ namespace ms for (auto iter : CharEffect::PATHS) chareffects.emplace(iter.first, src.resolve(iter.second)); + for (int success = 0; success < 2; success++) + for (int layer = 0; layer < 2; layer++) + skill_book[success][layer] = src["SkillBook"][success ? "Success" : "Failure"][std::to_string(layer)]; + nl::node tomb = nl::nx::effect["Tomb.img"]; tomb_fall = tomb["fall"]; tomb_land = tomb["land"]; @@ -620,4 +630,5 @@ namespace ms EnumMap Char::chareffects; Animation Char::tomb_fall; Animation Char::tomb_land; + Animation Char::skill_book[2][2]; } \ No newline at end of file diff --git a/HeavenClient/Character/Char.h b/HeavenClient/Character/Char.h index 1da31e20f..fbbb7719e 100644 --- a/HeavenClient/Character/Char.h +++ b/HeavenClient/Character/Char.h @@ -163,6 +163,10 @@ namespace ms // Display the iron body skill animation. void show_iron_body(); + // Display a skill or mastery book's glow: its first layer over the + // character, its second behind (v83 FUN_0043cba9, z 3 and -1). + void show_skill_book(bool success); + // Display damage over the characters head, with the hit face for // 1.5 s if it hurt, as v83 does for every user (0x9832e3). void show_damage(int32_t damage); @@ -298,5 +302,7 @@ namespace ms static EnumMap chareffects; static Animation tomb_fall; static Animation tomb_land; + // BasicEff.img/SkillBook: [failure, success][front, back] + static Animation skill_book[2][2]; }; } \ No newline at end of file diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 2826ddc05..2399584ff 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -191,7 +191,9 @@ namespace ms if (int16_t slot = inventory.find_item(type, itemid)) { - if (type == InventoryType::Id::USE) + if (type == InventoryType::Id::USE && (itemid / 10000 == 228 || itemid / 10000 == 229)) + request(SkillBookPacket(slot, itemid), ExclRequest::USE_WAIT); + else if (type == InventoryType::Id::USE) request(UseItemPacket(slot, itemid), ExclRequest::USE_WAIT); else if (type == InventoryType::Id::SETUP && itemid / 10000 == 301) sit_on_chair(itemid); diff --git a/HeavenClient/Gameplay/FameCooldown.h b/HeavenClient/Gameplay/FameCooldown.h new file mode 100644 index 000000000..e4a3f3abc --- /dev/null +++ b/HeavenClient/Gameplay/FameCooldown.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace ms +{ + // v83 sends GIVE_FAME at most once every 5 minutes (FUN_00a23eb5); a + // press in between says "Please try it again later." and sends nothing. + class FameCooldown + { + public: + // Whether fame may be given at now_ms; if so, it starts the wait. + bool try_give(int64_t now_ms) + { + if (given && now_ms - last < DELAY) + return false; + + given = true; + last = now_ms; + + return true; + } + + private: + static constexpr int64_t DELAY = 300000; + + bool given = false; + int64_t last = 0; + }; +} diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index b9f523019..f7f47e43f 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -79,6 +79,7 @@ namespace ms start = ContinuousTimer::get().start(); emote_cooldown = {}; + fame_cooldown = {}; CharStats stats = player.get_stats(); levelBefore = stats.get_stat(MapleStat::Id::LEVEL); @@ -679,6 +680,11 @@ namespace ms effect = MapEffect(path); } + bool Stage::try_give_fame() + { + return fame_cooldown.try_give(get_uptime() / 1000); + } + int64_t Stage::get_uptime() { return ContinuousTimer::get().stop(start); diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index 7a276bdee..fb0bf2df5 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -24,6 +24,7 @@ #include "Combat/Combat.h" #include "EmoteCooldown.h" +#include "FameCooldown.h" #include "MapleMap/MapInfo.h" #include "MapleMap/MapTilesObjs.h" #include "MapleMap/MapBackgrounds.h" @@ -128,6 +129,9 @@ namespace ms // Set a map effect void add_effect(std::string path); + // Whether the player may give fame now (once every 5 minutes); if + // so, the wait starts. + bool try_give_fame(); // Get the total up time of the client int64_t get_uptime(); // Get the level at the start of the client @@ -215,6 +219,7 @@ namespace ms Combat combat; EmoteCooldown emote_cooldown; + FameCooldown fame_cooldown; std::chrono::time_point start; uint16_t levelBefore; diff --git a/HeavenClient/IO/UITypes/UICharInfo.cpp b/HeavenClient/IO/UITypes/UICharInfo.cpp index 904f2f250..bc6e7d840 100644 --- a/HeavenClient/IO/UITypes/UICharInfo.cpp +++ b/HeavenClient/IO/UITypes/UICharInfo.cpp @@ -21,6 +21,9 @@ #include "../../Character/Job.h" #include "../../Gameplay/Stage.h" +#include "../../IO/UI.h" +#include "../../IO/UITypes/UIChatBar.h" +#include "../../Net/Packets/PlayerInteractionPackets.h" #include "../../Util/NxFiles.h" #include @@ -93,10 +96,18 @@ namespace ms buttons[Buttons::BT_EXCEPTION] = std::make_unique(UserInfo["BtExceptionShow"]); buttons[Buttons::BT_PANEL_HIDE] = std::make_unique(Basic["BtHide"], PANEL + PANEL_HIDE); - // There are no families, parties, trades, fame, or pets yet. - for (auto button : { Buttons::BT_FAMILY, Buttons::BT_PARTY, Buttons::BT_TRADE, Buttons::BT_FAME_UP, Buttons::BT_FAME_DOWN, Buttons::BT_EXCEPTION }) + // There are no families, parties, trades, or pets yet. + for (auto button : { Buttons::BT_FAMILY, Buttons::BT_PARTY, Buttons::BT_TRADE, Buttons::BT_EXCEPTION }) buttons[button]->set_state(Button::State::DISABLED); + // v83 offers fame only from level 15 (FUN_009007a0), and never for + // oneself (FUN_00903b86). + auto &player = Stage::get().get_player(); + + if (cid == player.get_oid() || player.get_stats().get_stat(MapleStat::Id::LEVEL) < 15) + for (auto button : { Buttons::BT_FAME_UP, Buttons::BT_FAME_DOWN }) + buttons[button]->set_state(Button::State::DISABLED); + name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::WHITE); level = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK); job = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK); @@ -155,6 +166,12 @@ namespace ms case Buttons::BT_PANEL_HIDE: set_panel(Panel::NO_PANEL); break; + case Buttons::BT_FAME_UP: + give_fame(true); + break; + case Buttons::BT_FAME_DOWN: + give_fame(false); + break; case Buttons::BT_PET_SHOW: set_mode(Mode::PET); break; @@ -209,6 +226,31 @@ namespace ms alliance.change_text(a.empty() ? "-" : a); } + void UICharInfo::change_fame(int16_t f) + { + fame.change_text(std::to_string(f)); + } + + void UICharInfo::give_fame(bool up) const + { + const char *refusal = nullptr; + + if (!Stage::get().get_character(cid)) + refusal = "Unable to find the character."; + else if (!Stage::get().try_give_fame()) + refusal = "Please try it again later."; + + if (refusal) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(refusal, UIChatBar::LineType::SKILL); + + return; + } + + GiveFamePacket(cid, up).dispatch(); + } + void UICharInfo::set_mode(Mode m) { mode = m; diff --git a/HeavenClient/IO/UITypes/UICharInfo.h b/HeavenClient/IO/UITypes/UICharInfo.h index 0c58b54bc..c07aae1f9 100644 --- a/HeavenClient/IO/UITypes/UICharInfo.h +++ b/HeavenClient/IO/UITypes/UICharInfo.h @@ -48,8 +48,12 @@ namespace ms UIElement::Type get_type() const override; void update_stats(int32_t character_id, int16_t job_id, int8_t level, int16_t fame, std::string guild, std::string alliance); + // Show the fame the player just raised or dropped (FAME_RESPONSE). + void change_fame(int16_t fame); private: + void give_fame(bool up) const; + // The main window alone, or lengthened to show pets, the mount, or // the monster book collection. enum Mode : uint8_t diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 6c6d67dfc..e2db2d600 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -235,6 +235,12 @@ namespace ms update_slot(slot); } + void UIItemInventory::refresh_slots() + { + load_icons(); + update_slider(); + } + void UIItemInventory::update_slider() { int16_t total = (inventory.get_slotmax(tab) + COLUMNS - 1) / COLUMNS; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.h b/HeavenClient/IO/UITypes/UIItemInventory.h index c455213be..071b5d006 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.h +++ b/HeavenClient/IO/UITypes/UIItemInventory.h @@ -62,6 +62,9 @@ namespace ms void clear_new(); + // Show a tab's new slot count. + void refresh_slots(); + protected: Button::State button_pressed(uint16_t buttonid) override; diff --git a/HeavenClient/Net/Handlers/InventoryHandlers.cpp b/HeavenClient/Net/Handlers/InventoryHandlers.cpp index 3ec0ad452..59eca8c80 100644 --- a/HeavenClient/Net/Handlers/InventoryHandlers.cpp +++ b/HeavenClient/Net/Handlers/InventoryHandlers.cpp @@ -46,6 +46,17 @@ namespace ms } } + void InventoryGrowHandler::handle(InPacket& recv) const + { + InventoryType::Id type = InventoryType::by_value(recv.read_byte()); + uint8_t slots = recv.read_byte(); + + Stage::get().get_player().get_inventory().set_slotmax(type, slots); + + if (auto iteminventory = UI::get().get_element()) + iteminventory->refresh_slots(); + } + void ModifyInventoryHandler::handle(InPacket& recv) const { // Set when this answers the player's item request. diff --git a/HeavenClient/Net/Handlers/InventoryHandlers.h b/HeavenClient/Net/Handlers/InventoryHandlers.h index a45e8288c..8acb3f809 100644 --- a/HeavenClient/Net/Handlers/InventoryHandlers.h +++ b/HeavenClient/Net/Handlers/InventoryHandlers.h @@ -33,6 +33,13 @@ namespace ms void handle(InPacket& recv) const override; }; + // Handler for a packet which gives an inventory tab more slots (v83 + // FUN_00a1f881): type, then the new slot count. + class InventoryGrowHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Handler for a packet which modifies the player's inventory. class ModifyInventoryHandler : public PacketHandler { diff --git a/HeavenClient/Net/Handlers/PlayerHandlers.cpp b/HeavenClient/Net/Handlers/PlayerHandlers.cpp index 4f8ea316b..39ce0f696 100644 --- a/HeavenClient/Net/Handlers/PlayerHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerHandlers.cpp @@ -19,6 +19,7 @@ #include "Helpers/BuffParser.h" #include "Helpers/LoginParser.h" +#include "../../Audio/Audio.h" #include "../Packets/LoginPackets.h" @@ -253,6 +254,52 @@ namespace ms UI::get().enable(); } + void SkillUseResultHandler::handle(InPacket& recv) const + { + recv.skip_byte(); + + Stage::get().get_player().request_answered(); + } + + void SkillLearnItemResultHandler::handle(InPacket& recv) const + { + int32_t cid = recv.read_int(); + auto character = Stage::get().get_character(cid); + + if (!character) + return; + + bool mastery = recv.read_bool(); + recv.skip_int(); // skill + recv.skip_int(); // master level + bool used = recv.read_bool(); + bool success = recv.read_bool(); + + if (used) + character->show_skill_book(success); + + if (!Stage::get().is_player(cid)) + return; + + Stage::get().get_player().request_answered(); + Sound(success ? Sound::Name::ENCHANTSUCCESS : Sound::Name::ENCHANTFAILURE).play(); + + std::string book = mastery ? "Mastery Book" : "Skill Book"; + std::string message; + + if (!used) + message = "You cannot use " + book + "."; + else if (!success) + message = "Despite using " + book + ", the effect was nowhere to be found."; + else if (mastery) + message = "The Book of Mastery glows brightly, and the current skills have gone through an upgrade."; + else + message = "The Skill Book glows brightly, and new skills have now been added."; + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(message, UIChatBar::LineType::SKILL); + } + void SkillMacrosHandler::handle(InPacket& recv) const { uint8_t size = recv.read_byte(); diff --git a/HeavenClient/Net/Handlers/PlayerHandlers.h b/HeavenClient/Net/Handlers/PlayerHandlers.h index 3404ad625..4eec0a267 100644 --- a/HeavenClient/Net/Handlers/PlayerHandlers.h +++ b/HeavenClient/Net/Handlers/PlayerHandlers.h @@ -84,6 +84,20 @@ namespace ms void handle(InPacket& recv) const override; }; + // Handler for a packet which answers the player's skill use (v83 + // FUN_00a209b2): it lets the player send the next request. + class SkillUseResultHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Handler for a packet which tells how someone's skill or mastery book + // went (v83 FUN_00a1e5af). + class SkillLearnItemResultHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Parses skill macros // Opcode: SKILL_MACROS(124) class SkillMacrosHandler : public PacketHandler diff --git a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp index 201b7107f..82d85bd3d 100644 --- a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp @@ -21,6 +21,7 @@ #include "../../Gameplay/Stage.h" #include "../../IO/UITypes/UICharInfo.h" +#include "../../IO/UITypes/UIChatBar.h" namespace ms { @@ -87,4 +88,65 @@ namespace ms if (auto charinfo = UI::get().get_element()) charinfo->update_stats(character_id, character_job_id, character_level, character_fame, guild_name, alliance_name); } -} \ No newline at end of file + + namespace + { + void fame_line(const std::string &message) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(message, UIChatBar::LineType::SKILL); + } + + // v83's lines put the name in '%s''s, quotes and all. + std::string fame_named(const std::string &format, const std::string &name) + { + std::string line = format; + line.replace(line.find("%s"), 2, name); + + return line; + } + } + + void FameResponseHandler::handle(InPacket& recv) const + { + switch (recv.read_byte()) + { + case 0: + { + std::string target = recv.read_string(); + bool up = recv.read_bool(); + int32_t fame = recv.read_int(); + + if (auto charinfo = UI::get().get_element()) + charinfo->change_fame(static_cast(fame)); + + fame_line(fame_named(up ? "You have raised '%s''s level of fame." : "You have dropped '%s''s level of fame.", target)); + break; + } + case 1: + fame_line("The user name is incorrectly entered."); + break; + case 2: + fame_line("Users under level 15 are unable to toggle with fame."); + break; + case 3: + fame_line("You can't raise or drop a level of fame anymore for today."); + break; + case 4: + fame_line("You can't raise or drop a level of fame of that character anymore for this month."); + break; + case 5: + { + std::string giver = recv.read_string(); + bool up = recv.read_bool(); + std::string line = fame_named(up ? "'%s' have raised '%s''s level of fame." : "'%s' have dropped '%s''s level of fame.", giver); + + fame_line(fame_named(line, Stage::get().get_player().get_name())); + break; + } + default: + fame_line("The level of fame has neither been raised or dropped due to an unexpected error."); + break; + } + } +} diff --git a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.h b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.h index b72f88974..aa047f766 100644 --- a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.h +++ b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.h @@ -26,4 +26,11 @@ namespace ms { void handle(InPacket& recv) const override; }; + + // What giving fame came to (v83 FUN_00a223dc): a line in chat, and for + // the giver, the new fame in the open info window. + class FameResponseHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; } \ No newline at end of file diff --git a/HeavenClient/Net/Handlers/SetFieldHandlers.cpp b/HeavenClient/Net/Handlers/SetFieldHandlers.cpp index 638d82bb6..419ae73b1 100644 --- a/HeavenClient/Net/Handlers/SetFieldHandlers.cpp +++ b/HeavenClient/Net/Handlers/SetFieldHandlers.cpp @@ -28,6 +28,8 @@ #include "../../Gameplay/Stage.h" #include "../../IO/UI.h" +#include "../../IO/UITypes/UIChatBar.h" +#include "../../IO/UITypes/UINotice.h" #include "../../IO/Window.h" #include "../../IO/UITypes/UICharSelect.h" @@ -176,4 +178,73 @@ namespace ms UI::get().change_state(UI::State::GAME); } + + namespace + { + void blocked_line(const char *message) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(message, UIChatBar::LineType::SKILL); + } + } + + void BlockedMapHandler::handle(InPacket& recv) const + { + auto &player = Stage::get().get_player(); + player.request_answered(); + + // HeavenClient notes the portal's map when it asks; it stays put. + player.get_stats().set_mapid(Stage::get().get_mapid()); + + switch (recv.read_byte()) + { + case 1: + blocked_line("The portal is closed for now."); + break; + case 2: + blocked_line("You cannot go to that place."); + break; + case 3: + case 5: + blocked_line("Unable to approach due to the force of the ground."); + break; + case 4: + UI::get().emplace("You cannot teleport to or on this map.", [](bool) {}); + break; + case 6: + blocked_line("This map can only be entered by party members."); + break; + case 7: + UI::get().emplace("The cash shop is currently\\nnot available.\\nStay tuned...", [](bool) {}); + break; + default: + break; + } + } + + void BlockedServerHandler::handle(InPacket& recv) const + { + Stage::get().get_player().request_answered(); + + switch (recv.read_byte()) + { + case 1: + blocked_line("You cannot move that channel. Please try again later."); + break; + case 2: + blocked_line("You cannot go into the cash shop. Please try again later."); + break; + case 3: + blocked_line("The Item-Trading Shop is currently unavailable. Please try again later."); + break; + case 4: + blocked_line("You cannot go into the trade shop, due to the limitation of user count."); + break; + case 5: + blocked_line("You do not meet the minimum level requirement to access the Trade Shop."); + break; + default: + break; + } + } } diff --git a/HeavenClient/Net/Handlers/SetFieldHandlers.h b/HeavenClient/Net/Handlers/SetFieldHandlers.h index 9edeea61b..2b421cc03 100644 --- a/HeavenClient/Net/Handlers/SetFieldHandlers.h +++ b/HeavenClient/Net/Handlers/SetFieldHandlers.h @@ -34,4 +34,20 @@ namespace ms void change_map(InPacket& recv, int32_t map_id) const; void set_field(InPacket& recv) const; }; -} \ No newline at end of file + + // A map change the server refused (v83 FUN_0053185c): the request is + // answered, and the reason goes to chat or, for 4 and 7, a notice. + class BlockedMapHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; + + // A channel, cash shop or trade shop move the server refused (v83 + // FUN_00531a08): the request is answered and the reason goes to chat. + class BlockedServerHandler : public PacketHandler + { + public: + void handle(InPacket& recv) const override; + }; +} diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 9ef70b2b5..cbd69d1ee 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -89,12 +89,14 @@ namespace ms /// Player Interaction CHAR_INFO_REQUEST = 97, + GIVE_FAME = 95, /// Inventory GATHER_ITEMS = 69, SORT_ITEMS = 70, MOVE_ITEM = 71, USE_ITEM = 72, + USE_SKILL_BOOK = 82, MESO_DROP = 94, HEAL_OVER_TIME = 89, SCROLL_EQUIP = 86, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index dcbb013ed..d530ee6ca 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -58,6 +58,7 @@ namespace ms /// Inventory 1 MODIFY_INVENTORY = 29, + INVENTORY_GROW = 30, /// Player 2 CHANGE_CHANNEL = 16, @@ -66,6 +67,9 @@ namespace ms CANCEL_BUFF = 33, RECALCULATE_STATS = 35, UPDATE_SKILL = 36, + SKILL_USE_RESULT = 37, + FAME_RESPONSE = 38, + SKILL_LEARN_ITEM_RESULT = 51, /// Messaging 1 SHOW_STATUS_INFO = 39, @@ -88,6 +92,8 @@ namespace ms SKILL_MACROS = 124, SET_FIELD = 125, + BLOCKED_MAP = 131, + BLOCKED_SERVER = 132, WHISPER = 135, MESSENGER = 313, FIELD_EFFECT = 138, @@ -187,6 +193,8 @@ namespace ms // SetField handlers emplace(); + emplace(); + emplace(); // MapObject handlers emplace(); @@ -239,6 +247,8 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); + emplace(); emplace(); emplace(); emplace(); @@ -257,6 +267,7 @@ namespace ms // Inventory Handlers emplace(); + emplace(); emplace(); emplace(); @@ -267,6 +278,7 @@ namespace ms // Player Interaction emplace(); + emplace(); // Party handlers emplace(); diff --git a/HeavenClient/Net/Packets/InventoryPackets.h b/HeavenClient/Net/Packets/InventoryPackets.h index 5a687bc32..2f99fadbf 100644 --- a/HeavenClient/Net/Packets/InventoryPackets.h +++ b/HeavenClient/Net/Packets/InventoryPackets.h @@ -107,6 +107,19 @@ namespace ms } }; + // Requests reading a skill or mastery book (v83 FUN_00a0a1b2) + // Opcode: USE_SKILL_BOOK(82) + class SkillBookPacket : public OutPacket + { + public: + SkillBookPacket(int16_t slot, int32_t itemid) : OutPacket(OutPacket::Opcode::USE_SKILL_BOOK) + { + write_time(); + write_short(slot); + write_int(itemid); + } + }; + // Requests using a scroll on an equip // Opcode: SCROLL_EQUIP(86) class ScrollEquipPacket : public OutPacket diff --git a/HeavenClient/Net/Packets/PlayerInteractionPackets.h b/HeavenClient/Net/Packets/PlayerInteractionPackets.h index de5a150d5..1140df060 100644 --- a/HeavenClient/Net/Packets/PlayerInteractionPackets.h +++ b/HeavenClient/Net/Packets/PlayerInteractionPackets.h @@ -34,4 +34,16 @@ namespace ms write_int(character_id); } }; + + // Raise (or drop) the fame of a character on the map (v83 FUN_00a23eb5) + // Opcode: GIVE_FAME(95) + class GiveFamePacket : public OutPacket + { + public: + GiveFamePacket(int32_t character_id, bool up) : OutPacket(OutPacket::Opcode::GIVE_FAME) + { + write_int(character_id); + write_byte(up); + } + }; } \ No newline at end of file diff --git a/HeavenClient/tests/fame_cooldown.cpp b/HeavenClient/tests/fame_cooldown.cpp new file mode 100644 index 000000000..406c9bb97 --- /dev/null +++ b/HeavenClient/tests/fame_cooldown.cpp @@ -0,0 +1,32 @@ +#include "../Gameplay/FameCooldown.h" + +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + ms::FameCooldown cooldown; + + require(cooldown.try_give(1000), "the first fame is never held back"); + require(!cooldown.try_give(1001), "a press right after giving fame sends nothing"); + require(!cooldown.try_give(300999), "the wait lasts 5 minutes"); + require(cooldown.try_give(301000), "fame may be given once 5 minutes have passed"); + require(!cooldown.try_give(600999), "a refused press doesn't extend the wait"); + require(cooldown.try_give(601000), "presses after the wait work again"); + + std::cout << "Fame cooldown passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/client.md b/agent_docs/client.md index 66d9ed65e..7e740785c 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -222,7 +222,13 @@ 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, pets, monster book, or cash shop wish list. Family, - party, trade, fame, and excluded items are disabled. + party, trade, and excluded items are disabled. The fame buttons send + GIVE_FAME (`GiveFamePacket`) unless the window is the player's own or the + player is below level 15. As in v83 (FUN_00a23eb5), the client refuses a + character who isn't on the map ("Unable to find the character.") and a + 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. - **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/fields.md b/agent_docs/fields.md index c09cfeccb..8e62f6f55 100644 --- a/agent_docs/fields.md +++ b/agent_docs/fields.md @@ -62,6 +62,22 @@ the field's projection. Guild, alliance, pets, mount, wish list, and monster book are empty; the medal is the item worn in the medal slot. A request for someone not on the map gets no answer. +## Fame + +The fame buttons in someone else's character info send GIVE_FAME (0x5F: +character id, `u8` up). `GiveFameHandler` ignores fame for yourself and tells a +giver below level 15 they can't (FAME_RESPONSE 0x26 mode 2); otherwise it +returns `WorldAction::GiveFame`. The giver's client actor records the gift in +`fame_log` (`db::fame::give`), refusing an unknown character (mode 1), a second +gift within a day (mode 3), or a gift to the same character within 30 days +(mode 4). Then it passes `FieldRequest::Fame` to its field. If the target is +on the map, the field sends their actor `ServerMessage::Famed`. The target +changes its own fame by one, within ±30000, and sends the stat update and +mode 5 (giver, up) to its player and mode 0 (target, up, new fame) to the +giver. A target not on the map sends the giver `ServerMessage::FameMissed`, +which deletes the log row (`db::fame::forget`) and answers mode 1, so the miss +doesn't spend the day. + ## Map changes `ChangeMapHandler` resolves the destination (portal link or permitted map-id @@ -72,6 +88,16 @@ joins the new one at the spawn portal's position. World entry uses the same spawn-portal lookup (`net::spawn_position`) instead of a hard-coded position. +The client waits for an answer to each move it asks for, so a refusal +answers too. v83 has two refusal packets, each a reason byte +(`net::packet::build::world::map`): `BLOCKED_MAP` (0x83) for map changes and +`BLOCKED_SERVER` (0x84) for channel, cash shop and trade shop moves. +HeavenClient shows the reason in chat or as a notice. The server sends +`BLOCKED_MAP` 1 ("The portal is closed for now.") for a portal to a map or spawn +portal it doesn't have. It sends `BLOCKED_SERVER` 1 for a change to the +player's own channel or to a missing channel, and 2 when a dead player tries +to enter the Cash Shop. + ### The Cash Shop `ENTER_CASHSHOP` (0x28) takes the player off their field, saves them, and diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index a7887b723..56444e6e6 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -105,6 +105,22 @@ within that interval of the player's last PLAYER_MOVE, and MP at most once per 10 s, each with a second of slack; anything else is ignored. Endure's intervals are never shorter than 10 s, so climbing needs no separate check. +### Skill and mastery books + +USE_SKILL_BOOK (`0x52`: tick, slot, item id) reads a skill book (228xxxx) +or a mastery book (229xxxx). v83 sends it from the item-use path with the +usual exclusive-request gate and checks nothing itself. The book's +`info/skill` lists skills; the server takes the one whose job +(skill / 10000) is the reader's own job, and refuses the book (kept, `used` +0) when there is none, when the reader's level in it is below +`info/reqSkillLevel`, or when their master level is already the book's +`info/masterLevel`. Otherwise it spends the book and rolls `info/success` +(percent, 100 when absent): on success the skill's master level becomes the +book's, sent with UPDATE_SKILLS. SKILL_LEARN_ITEM_RESULT (`0x33`: reader, +mastery, skill, master level, used, success) goes to the reader and the map. +HeavenClient plays the `BasicEff.img/SkillBook` glow on the reader, and for +the reader the Enchant sound and the v83 chat line. + ## NPC shops Clicking an NPC that keeps a shop (`content::shops()`, from the pack's @@ -149,4 +165,5 @@ to pick up and drops mesos earned from snails, checking both losses are saved; `net::shop` covers buying, selling, recharging, and their refusals, and `npc_shops` (integration, needs the pack's `shops.json`) opens the Henesys grocer, is refused for want of mesos, sells a sword, buys an arrow with it, -and checks both are saved. +and checks both are saved. `skill_books` (integration) refuses a mastery +book for another job and teaches Fire Demon to an Arch Mage. diff --git a/db/migrations/2026-10-22-000000_fame_log/down.sql b/db/migrations/2026-10-22-000000_fame_log/down.sql new file mode 100644 index 000000000..0977e5093 --- /dev/null +++ b/db/migrations/2026-10-22-000000_fame_log/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS fame_log; diff --git a/db/migrations/2026-10-22-000000_fame_log/up.sql b/db/migrations/2026-10-22-000000_fame_log/up.sql new file mode 100644 index 000000000..a2adf05f4 --- /dev/null +++ b/db/migrations/2026-10-22-000000_fame_log/up.sql @@ -0,0 +1,9 @@ +-- Who raised or dropped whose fame, and when: a character may do so once a +-- day, and to the same character once a month. +CREATE TABLE fame_log ( + id SERIAL PRIMARY KEY, + giver_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + given_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE INDEX fame_log_giver ON fame_log (giver_id, given_at); diff --git a/db/src/fame.rs b/db/src/fame.rs new file mode 100644 index 000000000..93a6e8351 --- /dev/null +++ b/db/src/fame.rs @@ -0,0 +1,65 @@ +//! Fame given: a character may raise or drop someone's fame once a day, and +//! the same character's once a month (OdinMS-family rules). + +use crate::schema::{characters, fame_log}; +use diesel::dsl::{now, IntervalDsl}; +use diesel::prelude::*; + +/// Why a character may not give fame now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// There is no such character. + NotFound, + /// They gave fame in the last day. + NotToday, + /// They gave `target` fame in the last 30 days. + NotThisMonth, +} + +/// If `giver` may give `target` fame now, record that they did. +pub fn give(giver: i32, target: i32) -> crate::Result> { + let mut conn = crate::connection()?; + Ok(conn.transaction(|conn| -> diesel::QueryResult<_> { + let found: i64 = characters::table.find(target).count().get_result(conn)?; + if found == 0 { + return Ok(Err(Refusal::NotFound)); + } + let today: i64 = fame_log::table + .filter(fame_log::giver_id.eq(giver)) + .filter(fame_log::given_at.gt(now - 1.day())) + .count() + .get_result(conn)?; + if today > 0 { + return Ok(Err(Refusal::NotToday)); + } + let this_month: i64 = fame_log::table + .filter(fame_log::giver_id.eq(giver)) + .filter(fame_log::target_id.eq(target)) + .filter(fame_log::given_at.gt(now - 30.days())) + .count() + .get_result(conn)?; + if this_month > 0 { + return Ok(Err(Refusal::NotThisMonth)); + } + diesel::insert_into(fame_log::table) + .values((fame_log::giver_id.eq(giver), fame_log::target_id.eq(target))) + .execute(conn)?; + Ok(Ok(())) + })?) +} + +/// Take back the fame `giver` just gave `target`, which didn't reach them. +pub fn forget(giver: i32, target: i32) -> crate::Result<()> { + let mut conn = crate::connection()?; + let latest: Option = fame_log::table + .filter(fame_log::giver_id.eq(giver)) + .filter(fame_log::target_id.eq(target)) + .order(fame_log::id.desc()) + .select(fame_log::id) + .first(&mut conn) + .optional()?; + if let Some(id) = latest { + diesel::delete(fame_log::table.find(id)).execute(&mut conn)?; + } + Ok(()) +} diff --git a/db/src/lib.rs b/db/src/lib.rs index 207062d03..9ea90531b 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -22,6 +22,7 @@ pub mod account; pub mod buddy; pub mod cash_shop; pub mod character; +pub mod fame; pub mod keybinding; pub mod player; pub mod quest; diff --git a/db/src/schema.rs b/db/src/schema.rs index d94f32bd0..8754a3803 100644 --- a/db/src/schema.rs +++ b/db/src/schema.rs @@ -231,6 +231,14 @@ diesel::table! { } } diesel::joinable!(monster_book_cards -> characters (character_id)); +diesel::table! { + fame_log (id) { + id -> Int4, + giver_id -> Int4, + target_id -> Int4, + given_at -> Timestamp, + } +} diesel::allow_tables_to_appear_in_same_query!( accounts, buddies, @@ -245,4 +253,5 @@ diesel::allow_tables_to_appear_in_same_query!( cash_sales, wish_lists, monster_book_cards, + fame_log, ); diff --git a/game-data/src/items.rs b/game-data/src/items.rs index 4768841bb..4f2cf320d 100644 --- a/game-data/src/items.rs +++ b/game-data/src/items.rs @@ -26,9 +26,24 @@ pub struct ItemInfo { pub recovery_mp: i32, /// `spec` of consumable items. pub spec: Option, + /// What a skill or mastery book teaches (`info/skill`). + pub book: Option, pub equip: Option, } +/// A skill book (228xxxx) or mastery book (229xxxx). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BookInfo { + /// The skills it can raise, one per job (`info/skill`). + pub skills: Vec, + /// The master level it grants (`masterLevel`). + pub master_level: i32, + /// The skill level the reader needs first (`reqSkillLevel`). + pub req_skill_level: i32, + /// Percent chance it works (`success`). + pub success: i32, +} + /// What using a consumable does (`spec`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ConsumeSpec { @@ -167,6 +182,18 @@ impl ItemData { info.unit_price = nx.real_child(meta, "unitPrice")?.unwrap_or(0.0); info.recovery_hp = nx.int_child(meta, "recoveryHP")?.unwrap_or(0); info.recovery_mp = nx.int_child(meta, "recoveryMP")?.unwrap_or(0); + if let Some(skills) = nx.child_by_name(meta, "skill")? { + let mut book = BookInfo { + master_level: nx.int_child(meta, "masterLevel")?.unwrap_or(0), + req_skill_level: nx.int_child(meta, "reqSkillLevel")?.unwrap_or(0), + success: nx.int_child(meta, "success")?.unwrap_or(100), + ..BookInfo::default() + }; + while let Some(skill) = nx.int_child(skills, &book.skills.len().to_string())? { + book.skills.push(skill); + } + info.book = Some(book); + } } if let Some(spec) = nx.child_by_name(node, "spec")? { info.spec = Some(ConsumeSpec { @@ -237,6 +264,7 @@ impl ItemData { recovery_hp: 0, recovery_mp: 0, spec: None, + book: None, equip: Some(equip), })) } @@ -288,6 +316,29 @@ mod tests { assert_eq!((relaxer.recovery_hp, relaxer.recovery_mp), (50, 0)); } + #[test] + fn reads_skill_and_mastery_books() { + let Some(data) = data() else { + return; + }; + let book = data.info(2290000).unwrap().unwrap().book.unwrap(); + assert_eq!( + book, + BookInfo { + skills: vec![1121001, 1221001, 1321001], + master_level: 20, + req_skill_level: 5, + success: 70, + } + ); + let book = data.info(2280000).unwrap().unwrap().book.unwrap(); + assert_eq!( + (book.skills, book.master_level, book.req_skill_level), + (vec![2121003], 10, 0) + ); + assert!(data.info(2010007).unwrap().unwrap().book.is_none()); + } + #[test] fn reads_monster_cards_and_their_mobs() { let Some(data) = data() else { diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index 6a4a439fb..2e5be5a4f 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -125,6 +125,20 @@ pub async fn grant_skill(character_id: i32, skill_id: i32, level: i32) -> Result .await } +/// Make the fame `giver_id` gave look `days` days older, as though that +/// many days had passed since. +pub async fn backdate_fame(giver_id: i32, days: i32) -> Result<(), HarnessError> { + change_one_row("backdate fame", move |conn| { + diesel::sql_query( + "UPDATE fame_log SET given_at = given_at - make_interval(days => $2) WHERE giver_id = $1", + ) + .bind::(giver_id) + .bind::(days) + .execute(conn) + }) + .await +} + async fn change_one_row( phase: &'static str, query: impl FnOnce(&mut PgConnection) -> QueryResult + Send + 'static, diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index bd23a964f..cfd40a388 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -116,6 +116,16 @@ pub fn use_item(slot: i16, item_id: i32) -> Packet { p } +/// USE_SKILL_BOOK of the skill or mastery book in a USE slot. +pub fn use_skill_book(slot: i16, item_id: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::UseSkillBook as i16).unwrap(); + p.write_int(0).unwrap(); + p.write_short(slot).unwrap(); + p.write_int(item_id).unwrap(); + p +} + /// SPEND_AP on the stat with this STAT_CHANGED mask bit. pub fn spend_ap(stat: i32) -> Packet { let mut p = Packet::new_empty(); diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index 8d156529d..3efd6f809 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -116,6 +116,13 @@ pub struct Trace { pub cancelled_buffs: Vec, /// COOLDOWN: (skill id, seconds). pub cooldowns: Vec<(i32, i16)>, + /// BLOCKED_MAP and BLOCKED_SERVER: (opcode, reason). + pub blocked: Vec<(i16, u8)>, + /// SKILL_LEARN_ITEM_RESULT: (reader, skill, master level, used, success). + pub books: Vec<(i32, i32, i32, bool, bool)>, + /// FAME_RESPONSE: (mode, name, up, new fame); the name, up and fame are + /// empty, false and 0 where the mode has none. + pub fame: Vec<(u8, String, bool, i32)>, /// Mobs and drops on the current field. pub field: Field, } @@ -180,6 +187,37 @@ impl Trace { values.insert(bit, value); } self.stat_updates.push(values); + } else if op == SendOpcode::BlockedMap as i16 || op == SendOpcode::BlockedServer as i16 { + self.blocked.push((op, r.read_byte().unwrap())); + } else if op == SendOpcode::FameResponse as i16 { + let mode = r.read_byte().unwrap(); + let (name, up, fame) = match mode { + 0 => ( + r.read_str_with_length().unwrap(), + r.read_byte().unwrap() != 0, + r.read_int().unwrap(), + ), + 5 => ( + r.read_str_with_length().unwrap(), + r.read_byte().unwrap() != 0, + 0, + ), + _ => (String::new(), false, 0), + }; + self.fame.push((mode, name, up, fame)); + } else if op == SendOpcode::SkillBookResult as i16 { + let reader = r.read_int().unwrap(); + r.read_byte().unwrap(); + let skill_id = r.read_int().unwrap(); + let master_level = r.read_int().unwrap(); + let used = r.read_byte().unwrap() != 0; + self.books.push(( + reader, + skill_id, + master_level, + used, + r.read_byte().unwrap() != 0, + )); } else if op == SendOpcode::UpdateSkills as i16 { r.read_byte().unwrap(); for _ in 0..r.read_short().unwrap() { diff --git a/integration-harness/tests/change_channel.rs b/integration-harness/tests/change_channel.rs index 4d274bc8a..87dba6850 100644 --- a/integration-harness/tests/change_channel.rs +++ b/integration-harness/tests/change_channel.rs @@ -7,7 +7,6 @@ use integration_harness::preconditions::load_harness_config_or_fail; use integration_harness::trace::step; use integration_harness::{login_to_world_session, HarnessError}; use net::packet::op::SendOpcode; -use std::collections::BTreeMap; use tokio::time::{timeout, Duration}; async fn read_world_entry( @@ -204,13 +203,17 @@ async fn refused_channel_changes_still_answer() { .await .expect("player failed to login"); let c = &mut session.connection; - let only_unlock = [BTreeMap::new()]; - // The client waits on an answer before its next request, so asking for - // the channel it is on, or one that doesn't exist, still answers. + // the channel it is on, or one that doesn't exist, still answers: + // BLOCKED_SERVER 1, "You cannot move that channel." for (marker, channel) in [("same channel", 2), ("unknown channel", 200)] { let request = build_change_channel(channel).expect("build change-channel request"); let t = step(c, marker, request).await; - assert_eq!(t.stat_updates, only_unlock, "{marker}"); + assert_eq!( + t.blocked, + [(SendOpcode::BlockedServer as i16, 1)], + "{marker}" + ); + assert!(t.stat_updates.is_empty(), "{marker}"); } } diff --git a/integration-harness/tests/fame.rs b/integration-harness/tests/fame.rs new file mode 100644 index 000000000..a3929f2b5 --- /dev/null +++ b/integration-harness/tests/fame.rs @@ -0,0 +1,92 @@ +//! Raising and dropping fame (GIVE_FAME, FAME_RESPONSE): from level 15, on +//! the same map, once a day, and to the same character once a month. +//! Fame that reaches nobody doesn't count. + +use integration_harness::database::{backdate_fame, set_job}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{step, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::Duration; + +const MAP: i32 = 40_000; +const FAME: i32 = 0x20000; + +fn give_fame(target: i32, up: bool) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::GiveFame as i16).unwrap(); + p.write_int(target).unwrap(); + p.write_byte(u8::from(up)).unwrap(); + p +} + +fn refusal(mode: u8) -> Vec<(u8, String, bool, i32)> { + vec![(mode, String::new(), false, 0)] +} + +#[tokio::test] +async fn fame_goes_once_a_day_and_to_each_character_once_a_month() { + let giver_config = load_harness_config_or_fail().await; + let target_config = load_harness_config_or_fail().await; + let novice_config = load_harness_config_or_fail().await; + + let giver_id = login_to_world_session(&giver_config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&giver_config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(giver_id, 100, 20, 50) + .await + .expect("make a warrior"); + + let mut giver = relogin(&giver_config).await; + let mut target = login_to_world_session(&target_config).await.unwrap(); + let mut novice = login_to_world_session(&novice_config).await.unwrap(); + assert_ne!(novice.map_id, MAP, "the novice starts off the map"); + let (mut g, mut t) = (Trace::default(), Trace::default()); + assert_eq!(warp(&mut giver.connection, MAP, "", &mut g).await, MAP); + assert_eq!(warp(&mut target.connection, MAP, "", &mut t).await, MAP); + let target_id = target.character_id; + + // Fame for someone not on the map reaches nobody and doesn't count. + for (marker, missing) in [("nobody", i32::MAX), ("elsewhere", novice.character_id)] { + let mut g = step(&mut giver.connection, marker, give_fame(missing, true)).await; + until(&mut giver.connection, &mut g, |g| !g.fame.is_empty()).await; + assert_eq!(g.fame, refusal(1), "{marker}"); + } + assert_eq!( + warp(&mut novice.connection, MAP, "", &mut Trace::default()).await, + MAP + ); + + let n = step(&mut novice.connection, "novice", give_fame(target_id, true)).await; + assert_eq!(n.fame, refusal(2)); + + let s = step(&mut giver.connection, "self", give_fame(giver_id, true)).await; + assert!(s.fame.is_empty()); + + let mut t = Trace::default(); + let mut g = step(&mut giver.connection, "up", give_fame(target_id, true)).await; + until(&mut target.connection, &mut t, |t| !t.fame.is_empty()).await; + assert_eq!(t.fame, [(5, giver.character_name.clone(), true, 0)]); + assert_eq!(t.stat(FAME), Some(1)); + until(&mut giver.connection, &mut g, |g| !g.fame.is_empty()).await; + assert_eq!(g.fame, [(0, target.character_name.clone(), true, 1)]); + + let g = step(&mut giver.connection, "today", give_fame(target_id, false)).await; + assert_eq!(g.fame, refusal(3)); + + // Two days on, the same target is still refused for the month. + backdate_fame(giver_id, 2).await.expect("backdate the fame"); + let g = step(&mut giver.connection, "month", give_fame(target_id, false)).await; + assert_eq!(g.fame, refusal(4)); + + // ...but anyone else may be. + let novice_id = novice.character_id; + let mut g = step(&mut giver.connection, "down", give_fame(novice_id, false)).await; + until(&mut giver.connection, &mut g, |g| !g.fame.is_empty()).await; + assert_eq!(g.fame, [(0, novice.character_name.clone(), false, -1)]); +} diff --git a/integration-harness/tests/skill_books.rs b/integration-harness/tests/skill_books.rs new file mode 100644 index 000000000..8a13e8d39 --- /dev/null +++ b/integration-harness/tests/skill_books.rs @@ -0,0 +1,53 @@ +//! A skill book teaches its skill's master level to a job that has the +//! skill and is spent; a book for another job is refused and kept. + +use integration_harness::database::{grant_item, set_job}; +use integration_harness::hunting::use_skill_book; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::step; +use integration_harness::{login_to_world_session, relogin}; +use tokio::time::Duration; + +const USE: i16 = 2; +const ARCH_MAGE_FP: i16 = 212; +/// [Skill Book] Fire Demon: master level 10, always works. +const FIRE_DEMON_BOOK: i32 = 2280000; +const FIRE_DEMON: i32 = 2121003; +/// [Mastery Book] Monster Magnet, for Heroes, Paladins and Dark Knights. +const MONSTER_MAGNET_BOOK: i32 = 2290000; + +#[tokio::test] +async fn a_skill_book_teaches_its_job_and_no_other() { + let config = load_harness_config_or_fail().await; + let mage_id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(mage_id, ARCH_MAGE_FP, 120, 5000) + .await + .expect("make an Arch Mage"); + grant_item(mage_id, USE, 1, FIRE_DEMON_BOOK) + .await + .expect("grant the skill book"); + grant_item(mage_id, USE, 2, MONSTER_MAGNET_BOOK) + .await + .expect("grant the mastery book"); + + let mut mage = relogin(&config).await; + let c = &mut mage.connection; + + let t = step(c, "magnet", use_skill_book(2, MONSTER_MAGNET_BOOK)).await; + assert_eq!(t.books, [(mage_id, 0, 0, false, false)]); + assert!(t.removed.is_empty() && t.skills.is_empty()); + + // A slot holding something else is refused without a result. + let t = step(c, "wrong slot", use_skill_book(2, FIRE_DEMON_BOOK)).await; + assert!(t.books.is_empty() && t.removed.is_empty()); + + let t = step(c, "fire demon", use_skill_book(1, FIRE_DEMON_BOOK)).await; + assert_eq!(t.books, [(mage_id, FIRE_DEMON, 10, true, true)]); + assert_eq!(t.removed, [(USE as u8, 1)]); + assert_eq!(t.skills, [(FIRE_DEMON, 0)]); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 20e52b444..9ef9cb866 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -306,6 +306,11 @@ pub enum WorldAction { /// A skill the player used reached party members; see `PartySkill`. PartySkill(PartySkill), Buddy(BuddyRequest), + /// Raise (or drop) the fame of `target`, who must be on the player's map. + GiveFame { + target: i32, + up: bool, + }, /// Tell the player where a character is (`/find`). Find { target_name: String, @@ -403,6 +408,12 @@ pub enum FieldRequest { /// Show the player the info window for `character_id`, who must be on /// their map (and may be themselves). CharInfo { character_id: i32 }, + /// Have `target`, if they are here, take `giver_name`'s fame. + Fame { + target: i32, + giver_name: String, + up: bool, + }, } impl ReplyAction for WorldAction { @@ -481,6 +492,7 @@ fn world_routes() -> &'static HashMap { (Op::SortItems, InGame(&world::SortItemsHandler)), (Op::ItemMove, InGame(&world::ItemMoveHandler)), (Op::UseItem, InGame(&world::UseItemHandler)), + (Op::UseSkillBook, InGame(&world::UseSkillBookHandler)), (Op::MoveLife, InGame(&world::MoveLifeHandler)), (Op::MobDistance, InGame(&world::MobDistanceHandler)), (Op::NpcAction, InGame(&world::NpcActionHandler)), @@ -508,6 +520,7 @@ fn world_routes() -> &'static HashMap { (Op::ThrowGrenade, InGame(&world::ThrowGrenadeHandler)), (Op::MesoDrop, InGame(&world::MesoDropHandler)), (Op::CharInfoRequest, InGame(&world::CharInfoRequestHandler)), + (Op::GiveFame, InGame(&world::GiveFameHandler)), ]; table .into_iter() @@ -538,7 +551,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 47); + assert_eq!(world_routes().len(), 49); } #[test] diff --git a/net/src/packet/build/world/fame.rs b/net/src/packet/build/world/fame.rs new file mode 100644 index 000000000..f725d22e0 --- /dev/null +++ b/net/src/packet/build/world/fame.rs @@ -0,0 +1,84 @@ +use crate::{error::NetworkError, packet::op::SendOpcode}; +use packet::{io::write::PktWrite, Packet}; + +/// What GIVE_FAME came to, as FAME_RESPONSE tells it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FameResponse { + /// To the giver: `target`'s fame went up (or down) to `fame`. + Gave { target: String, up: bool, fame: i16 }, + /// "The user name is incorrectly entered." + NotFound, + /// "Users under level 15 are unable to toggle with fame." + UnderLevel, + /// "You can't raise or drop a level of fame anymore for today." + NotToday, + /// "...of that character anymore for this month." + NotThisMonth, + /// To the target: `giver` raised (or dropped) their fame. + Received { giver: String, up: bool }, + /// "The level of fame has neither been raised or dropped due to an + /// unexpected error." + Failed, +} + +/// FAME_RESPONSE (0x26, v83 FUN_00a223dc). The giver's chat names the +/// target; their open info window shows the new fame. +pub fn build_fame_response(response: &FameResponse) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::FameResponse as i16)?; + let mode = match response { + FameResponse::Gave { .. } => 0, + FameResponse::NotFound => 1, + FameResponse::UnderLevel => 2, + FameResponse::NotToday => 3, + FameResponse::NotThisMonth => 4, + FameResponse::Received { .. } => 5, + FameResponse::Failed => 6, + }; + packet.write_byte(mode)?; + match response { + FameResponse::Gave { target, up, fame } => { + packet.write_str_with_length(target)?; + packet.write_byte(u8::from(*up))?; + packet.write_int(i32::from(*fame))?; + } + FameResponse::Received { giver, up } => { + packet.write_str_with_length(giver)?; + packet.write_byte(u8::from(*up))?; + } + _ => {} + } + Ok(packet) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_giver_hears_the_new_fame_and_the_target_who_gave_it() { + let gave = FameResponse::Gave { + target: "Bob".into(), + up: false, + fame: -3, + }; + assert_eq!( + build_fame_response(&gave).unwrap().bytes, + [0x26, 0, 0, 3, 0, b'B', b'o', b'b', 0, 0xfd, 0xff, 0xff, 0xff] + ); + let received = FameResponse::Received { + giver: "Al".into(), + up: true, + }; + assert_eq!( + build_fame_response(&received).unwrap().bytes, + [0x26, 0, 5, 2, 0, b'A', b'l', 1] + ); + assert_eq!( + build_fame_response(&FameResponse::NotThisMonth) + .unwrap() + .bytes, + [0x26, 0, 4] + ); + } +} diff --git a/net/src/packet/build/world/map.rs b/net/src/packet/build/world/map.rs index 989ab62ba..4b19d536d 100644 --- a/net/src/packet/build/world/map.rs +++ b/net/src/packet/build/world/map.rs @@ -20,12 +20,74 @@ pub fn build_warp_to_map(stats: &CharacterStats, channel_id: u8) -> Result Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::BlockedMap as i16)?; + p.write_byte(reason as u8)?; + Ok(p) +} + +/// BLOCKED_SERVER (0x84): the move is refused and the client may ask again. +pub fn build_blocked_server(reason: BlockedServer) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::BlockedServer as i16)?; + p.write_byte(reason as u8)?; + Ok(p) +} + #[cfg(test)] mod tests { use super::*; use crate::codec::stats::sample_stats; use crate::codec::testing::Expect; + #[test] + fn a_refusal_names_its_reason() { + assert_eq!( + build_blocked_map(BlockedMap::PortalClosed).unwrap().bytes, + [0x83, 0, 1] + ); + assert_eq!( + build_blocked_server(BlockedServer::Channel).unwrap().bytes, + [0x84, 0, 1] + ); + } + #[test] fn warp_to_map_matches_reference() { let packet = build_warp_to_map(&sample_stats(), 1).unwrap(); diff --git a/net/src/packet/build/world/mod.rs b/net/src/packet/build/world/mod.rs index d1fc7dbb6..a4e2a0d19 100644 --- a/net/src/packet/build/world/mod.rs +++ b/net/src/packet/build/world/mod.rs @@ -5,6 +5,7 @@ pub mod channel; pub mod char; pub mod drop; pub mod effect; +pub mod fame; pub mod field; pub mod info; pub mod inventory; diff --git a/net/src/packet/build/world/skill.rs b/net/src/packet/build/world/skill.rs index 25f629afc..8b1f220cd 100644 --- a/net/src/packet/build/world/skill.rs +++ b/net/src/packet/build/world/skill.rs @@ -17,6 +17,34 @@ pub fn build_update_skill(skill: &SkillRecord) -> Result { Ok(packet) } +/// How reading a skill or mastery book went. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BookResult { + pub character_id: i32, + /// A mastery book (229xxxx) rather than a skill book (228xxxx). + pub mastery: bool, + pub skill_id: i32, + pub master_level: i32, + /// The reader could use it: it was spent. + pub used: bool, + pub success: bool, +} + +/// SKILL_LEARN_ITEM_RESULT (0x33, v83 FUN_00a1e5af): the map sees the book's +/// glow on the reader when it was used; the reader also gets the chat line +/// and their next request. +pub fn build_skill_book_result(result: &BookResult) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::SkillBookResult as i16)?; + packet.write_int(result.character_id)?; + packet.write_byte(u8::from(result.mastery))?; + packet.write_int(result.skill_id)?; + packet.write_int(result.master_level)?; + packet.write_byte(u8::from(result.used))?; + packet.write_byte(u8::from(result.success))?; + Ok(packet) +} + /// SKILL_EFFECT: another player started preparing a skill. The client /// takes `action_and_facing` and `speed` as that player sent them. pub fn build_skill_prepare( @@ -91,6 +119,22 @@ mod tests { expect.end(); } + #[test] + fn a_book_result_names_the_reader_skill_and_outcome() { + let packet = build_skill_book_result(&BookResult { + character_id: 7, + mastery: true, + skill_id: 1121001, + master_level: 20, + used: true, + success: false, + }) + .unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(0x33).int(7).byte(1).int(1121001).int(20); + expect.byte(1).byte(0).end(); + } + #[test] fn a_prepare_passes_on_the_action_and_facing_and_speed() { let packet = build_skill_prepare(7, 3121004, 20, 0x8016, 4).unwrap(); diff --git a/net/src/packet/handle/world/cash_shop.rs b/net/src/packet/handle/world/cash_shop.rs index 3aeab7ba1..b4a9dd47f 100644 --- a/net/src/packet/handle/world/cash_shop.rs +++ b/net/src/packet/handle/world/cash_shop.rs @@ -2,6 +2,7 @@ use crate::cash_shop::{self, CashShopRequest, Expansion}; use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; use crate::packet::build; use crate::packet::build::world::cash_shop::{CashShopFailure, GIFT_MESSAGE_LEN, WISH_LIST_SLOTS}; +use crate::packet::build::world::map::BlockedServer; use game::InventoryType; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; @@ -14,8 +15,9 @@ pub struct EnterCashShopHandler; impl WorldHandler for EnterCashShopHandler { fn handle(&self, _packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { if ctx.player.stats.hp <= 0 { - let enable_actions = build::world::stat::build_stat_update(true, &[])?; - return Ok(HandlerResult::reply(enable_actions)); + return Ok(HandlerResult::reply( + build::world::map::build_blocked_server(BlockedServer::CashShop)?, + )); } Ok(HandlerResult::empty().with(WorldAction::EnterCashShop)) } @@ -194,6 +196,26 @@ mod tests { .unwrap() } + #[test] + fn the_dead_are_kept_out_of_the_cash_shop() { + let mut player = crate::codec::stats::sample_player(); + player.stats.hp = 0; + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + let mut packet = Packet::new_empty(); + packet + .write_short(RecvOpcode::EnterCashShop as i16) + .unwrap(); + let result = EnterCashShopHandler.handle(&mut packet, &mut ctx).unwrap(); + assert!(matches!( + result.actions.as_slice(), + [WorldAction::Reply(p)] if p.bytes == [0x84, 0, 2] + )); + } + #[test] fn an_unknown_balance_fails_the_purchase() { let result = handle(|p| { diff --git a/net/src/packet/handle/world/change_channel.rs b/net/src/packet/handle/world/change_channel.rs index 7e14550e0..361a5e24d 100644 --- a/net/src/packet/handle/world/change_channel.rs +++ b/net/src/packet/handle/world/change_channel.rs @@ -1,6 +1,7 @@ use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; use crate::login_world::resolve_login_channel; use crate::packet::build; +use crate::packet::build::world::map::BlockedServer; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; @@ -18,8 +19,9 @@ impl WorldHandler for ChangeChannelHandler { if target_channel_id == ctx.channel_id || resolve_login_channel(ctx.player.world_id, target_channel_id).is_err() { - let enable_actions = build::world::stat::build_stat_update(true, &[])?; - return Ok(HandlerResult::reply(enable_actions)); + return Ok(HandlerResult::reply( + build::world::map::build_blocked_server(BlockedServer::Channel)?, + )); } Ok(HandlerResult::empty().with(WorldAction::ChangeChannel { diff --git a/net/src/packet/handle/world/change_map.rs b/net/src/packet/handle/world/change_map.rs index 1ec2784cb..0e52e117d 100644 --- a/net/src/packet/handle/world/change_map.rs +++ b/net/src/packet/handle/world/change_map.rs @@ -3,6 +3,7 @@ use crate::handler::{ HandlerResult, SpawnPosition, WorldAction, WorldContext, WorldHandler, WorldResult, }; use crate::packet::build; +use crate::packet::build::world::map::BlockedMap; use crate::policy; use game::Player; use packet::{io::read::PktRead, Packet}; @@ -44,22 +45,19 @@ impl WorldHandler for ChangeMapHandler { let source_portal = source_field .portal_by_name(portal_name.as_str()) .ok_or(NetworkError::PacketHandlerError("Source portal not found"))?; - let destination_map = source_portal - .to_map - .ok_or(NetworkError::PacketHandlerError( - "Portal has no destination", - ))?; - let destination_field = - game_data - .field(destination_map) - .ok_or(NetworkError::PacketHandlerError( - "Destination field not found", - ))?; - let spawn_portal = destination_field - .resolve_spawn_portal(source_portal.to_name.as_str()) - .ok_or(NetworkError::PacketHandlerError( - "Destination spawn portal not found", - ))?; + // A portal to a map or spawn portal this server lacks is closed, + // and the client may try again. + let Some((destination_map, spawn_portal)) = source_portal.to_map.and_then(|map_id| { + let field = game_data.field(map_id)?; + Some(( + map_id, + field.resolve_spawn_portal(source_portal.to_name.as_str())?, + )) + }) else { + return Ok(HandlerResult::reply(build::world::map::build_blocked_map( + BlockedMap::PortalClosed, + )?)); + }; let spawn_point = u8::try_from(spawn_portal.id).map_err(|_| { NetworkError::PacketHandlerError("Destination spawn portal id out of range") })?; diff --git a/net/src/packet/handle/world/fame.rs b/net/src/packet/handle/world/fame.rs new file mode 100644 index 000000000..31c9a93fa --- /dev/null +++ b/net/src/packet/handle/world/fame.rs @@ -0,0 +1,78 @@ +use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; +use crate::packet::build::world::fame::{build_fame_response, FameResponse}; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +/// The lowest level that may give fame. +pub const MIN_FAME_LEVEL: u8 = 15; + +/// `GIVE_FAME` (0x5F, v83 FUN_00a23eb5): character id, 1 to raise their fame +/// or 0 to drop it. The client sends it from a character's info window, for +/// someone on its map, at most once every five minutes. +pub struct GiveFameHandler; + +impl WorldHandler for GiveFameHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let target = reader.read_int()?; + let up = reader.read_byte()? != 0; + if target == ctx.client_id { + // The window has no fame buttons for oneself. + return Ok(HandlerResult::empty()); + } + if ctx.player.stats.level < MIN_FAME_LEVEL { + return Ok(HandlerResult::reply(build_fame_response( + &FameResponse::UnderLevel, + )?)); + } + Ok(HandlerResult::empty().with(WorldAction::GiveFame { target, up })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::op::RecvOpcode; + use packet::io::write::PktWrite; + + fn give(level: u8, target: i32) -> Vec { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::GiveFame as i16).unwrap(); + p.write_int(target).unwrap(); + p.write_byte(1).unwrap(); + let mut player = crate::codec::stats::sample_player(); + player.stats.level = level; + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + GiveFameHandler.handle(&mut p, &mut ctx).unwrap().actions + } + + #[test] + fn a_player_of_level_15_may_raise_someones_fame() { + assert!(matches!( + give(15, 62).as_slice(), + [WorldAction::GiveFame { + target: 62, + up: true + }] + )); + } + + #[test] + fn a_player_under_level_15_is_told_they_cant() { + let actions = give(14, 62); + let [WorldAction::Reply(packet)] = actions.as_slice() else { + panic!("expected a reply: {actions:?}"); + }; + assert_eq!(packet.bytes, [0x26, 0, 2]); + } + + #[test] + fn nobody_fames_themselves() { + assert!(give(30, 1).is_empty()); + } +} diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 1626d3f35..863dad63f 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -7,6 +7,7 @@ mod char_info; mod chat; mod combat; mod emote; +mod fame; mod item_move; mod keybinds; mod logged_in; @@ -40,6 +41,7 @@ pub use self::combat::{ AttackHandler, MobDistanceHandler, MoveLifeHandler, NpcActionHandler, TakeDamageHandler, }; pub use self::emote::FaceExpressionHandler; +pub use self::fame::{GiveFameHandler, MIN_FAME_LEVEL}; pub use self::item_move::ItemMoveHandler; pub use self::keybinds::ChangeKeybindsHandler; pub use self::logged_in::PlayerLoggedInHandler; @@ -58,7 +60,7 @@ pub use self::reactor::DamageReactorHandler; pub use self::rearrange::{GatherItemsHandler, SortItemsHandler}; pub use self::script_portal::ChangeMapSpecialHandler; pub use self::spend_points::{SpendApHandler, SpendSpHandler}; -pub use self::use_item::{HealOverTimeHandler, UseItemHandler}; +pub use self::use_item::{HealOverTimeHandler, UseItemHandler, UseSkillBookHandler}; pub use self::use_skill::{ party_skill_reached, CancelBuffHandler, SkillEffectHandler, ThrowGrenadeHandler, UseSkillHandler, diff --git a/net/src/packet/handle/world/use_item.rs b/net/src/packet/handle/world/use_item.rs index d5fe7b9ef..70a744382 100644 --- a/net/src/packet/handle/world/use_item.rs +++ b/net/src/packet/handle/world/use_item.rs @@ -1,11 +1,15 @@ -use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; +use crate::handler::{ + FieldRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, +}; use crate::items; +use crate::packet::build::world::skill::{self, BookResult}; use crate::packet::build::world::stat::StatUpdate; use crate::packet::build::world::{inventory, stat}; use crate::skills; use game::inventory::equip_slot; -use game::{InventoryType, Player}; +use game::{InventoryChange, InventoryType, Player, SkillRecord}; use packet::{io::read::PktRead, Packet}; +use rand::Rng; use std::io::Cursor; use tracing::debug; @@ -58,6 +62,100 @@ fn use_item(player: &mut Player, slot: i16, item_id: i32) -> Option WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + reader.read_int()?; // client tick + let slot = reader.read_short()?; + let item_id = reader.read_int()?; + let Some(read) = read_book(ctx.player, slot, item_id, |success| { + rand::thread_rng().gen_range(0, 100) < success + }) else { + debug!(slot, item_id, "Skill book use refused"); + return Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)); + }; + let mut result = HandlerResult::empty(); + if !read.changes.is_empty() { + result = result.with_reply(inventory::build_inventory_operation(true, &read.changes)?); + } + if let Some(record) = &read.record { + result = result.with_reply(skill::build_update_skill(record)?); + } + let shown = skill::build_skill_book_result(&read.result)?; + Ok(result + .with_reply(shown.clone()) + .with(WorldAction::Field(FieldRequest::Broadcast(shown)))) + } +} + +struct BookRead { + result: BookResult, + changes: Vec, + /// The skill with its new master level, when the book worked. + record: Option, +} + +/// Read the skill or mastery book in `slot`; `works(success)` rolls the +/// book's percent chance. `None` when there is no such book there. +fn read_book( + player: &mut Player, + slot: i16, + item_id: i32, + works: impl FnOnce(i32) -> bool, +) -> Option { + if player.inventory.get(InventoryType::Use, slot)?.item_id != item_id { + return None; + } + let book = items::info(item_id)?.book?; + let mut read = BookRead { + result: BookResult { + character_id: player.id(), + mastery: item_id / 10_000 == 229, + skill_id: 0, + master_level: 0, + used: false, + success: false, + }, + changes: Vec::new(), + record: None, + }; + let job = i32::from(player.stats.job); + let Some(&skill_id) = book.skills.iter().find(|&&skill| skill / 10_000 == job) else { + return Some(read); + }; + read.result.skill_id = skill_id; + read.result.master_level = book.master_level; + let mut record = player.skills.get(skill_id).cloned().unwrap_or(SkillRecord { + skill_id, + level: 0, + master_level: 0, + expiration: None, + }); + if record.level < book.req_skill_level || record.master_level >= book.master_level { + return Some(read); + } + read.changes = player + .inventory_mut() + .take(InventoryType::Use, slot, 1) + .ok()?; + read.result.used = true; + if works(book.success) { + record.master_level = book.master_level; + player.skills.set(record.clone()); + read.result.success = true; + read.record = Some(record); + } + Some(read) +} + /// `HEAL_OVER_TIME`: the client's periodic natural recovery. The runtime /// checks the timing; this only parses and bounds the amounts. pub struct HealOverTimeHandler; @@ -168,6 +266,88 @@ mod tests { assert!(player.inventory.get(InventoryType::Use, 3).is_none()); } + fn hero_with_book(slot: i16, item_id: i32) -> Player { + let mut player = crate::codec::stats::sample_player(); + player.stats.job = 112; + player + .inventory + .insert(InventoryType::Use, slot, Item::stack(item_id, 1)); + player + } + + fn skill(player: &Player, skill_id: i32) -> (i32, i32) { + player + .skills + .get(skill_id) + .map_or((0, 0), |s| (s.level, s.master_level)) + } + + #[test] + fn a_mastery_book_needs_its_skill_level_then_raises_the_master_level() { + if items::data().is_err() { + return; + } + // Mastery Book 2290000: Monster Magnet for Heroes, Paladins and Dark + // Knights to master level 20 from skill level 5, 70%. + let mut player = hero_with_book(2, 2290000); + let read = read_book(&mut player, 2, 2290000, |_| true).unwrap(); + assert_eq!((read.result.skill_id, read.result.used), (1121001, false)); + assert!(read.result.mastery && read.changes.is_empty()); + + player.skills.set(SkillRecord { + skill_id: 1121001, + level: 5, + master_level: 10, + expiration: None, + }); + let read = read_book(&mut player, 2, 2290000, |success| { + assert_eq!(success, 70); + false + }) + .unwrap(); + assert!(read.result.used && !read.result.success && read.record.is_none()); + assert_eq!(skill(&player, 1121001), (5, 10)); + assert!(player.inventory.get(InventoryType::Use, 2).is_none()); + + player + .inventory + .insert(InventoryType::Use, 2, Item::stack(2290000, 1)); + let read = read_book(&mut player, 2, 2290000, |_| true).unwrap(); + assert!(read.result.used && read.result.success); + assert_eq!(read.result.master_level, 20); + assert_eq!(skill(&player, 1121001), (5, 20)); + + // Already at the book's master level: not used, not spent. + player + .inventory + .insert(InventoryType::Use, 2, Item::stack(2290000, 1)); + let read = read_book(&mut player, 2, 2290000, |_| true).unwrap(); + assert!(!read.result.used); + assert!(player.inventory.get(InventoryType::Use, 2).is_some()); + } + + #[test] + fn a_book_for_another_job_is_not_used() { + if items::data().is_err() { + return; + } + // Skill Book 2280000 teaches Fire Demon (2121003) to Arch Mage (F/P). + let mut player = hero_with_book(1, 2280000); + let read = read_book(&mut player, 1, 2280000, |_| true).unwrap(); + assert_eq!( + read.result, + BookResult { + character_id: player.id(), + mastery: false, + skill_id: 0, + master_level: 0, + used: false, + success: false, + } + ); + assert!(read_book(&mut player, 1, 2290000, |_| true).is_none()); + } + #[test] fn mismatched_or_missing_items_are_refused() { let mut player = crate::codec::stats::sample_player(); diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 53ea184cf..bd8202c1b 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -53,6 +53,7 @@ pub enum RecvOpcode { SortItems = 0x46, ItemMove = 0x47, UseItem = 0x48, + UseSkillBook = 0x52, SpendAp = 0x57, HealOverTime = 0x59, SpendSp = 0x5A, @@ -60,6 +61,7 @@ pub enum RecvOpcode { CancelBuff = 0x5C, SkillEffect = 0x5D, MesoDrop = 0x5E, + GiveFame = 0x5F, CharInfoRequest = 0x61, ChangeMapSpecial = 0x64, QuestAction = 0x6B, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 4b9e4786d..09b6fd3bd 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -20,7 +20,9 @@ pub enum SendOpcode { CancelBuff = 0x21, StatChange = 0x1F, UpdateSkills = 0x24, + FameResponse = 0x26, ShowStatusInfo = 0x27, + SkillBookResult = 0x33, GatherItemResult = 0x34, SortItemResult = 0x35, @@ -33,6 +35,8 @@ pub enum SendOpcode { FamilyList = 0x64, SetField = 0x7D, SetCashShop = 0x7F, + BlockedMap = 0x83, + BlockedServer = 0x84, FieldEffect = 0x8A, MultiChat = 0x86, Whisper = 0x87, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index c16c80f5e..4622ac875 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -2,6 +2,7 @@ mod buddies; mod buff; mod combat; mod dialogue; +mod fame; mod loot; mod npc_script; mod quest; @@ -367,6 +368,7 @@ impl ClientActor { .await? } WorldAction::Buddy(request) => self.buddy_request(request).await?, + WorldAction::GiveFame { target, up } => self.give_fame(target, up).await?, WorldAction::Find { target_name } => { self.send_to_directory(DirectoryMessage::Find { from: self.client_id, @@ -936,6 +938,12 @@ impl ClientActor { .map_err(|e| RuntimeError::Handler(e.to_string()))?; requester.send_packet(packet); } + ServerMessage::Famed { + giver, + giver_name, + up, + } => self.take_fame(giver, giver_name, up).await?, + ServerMessage::FameMissed { target } => self.fame_missed(target).await?, ServerMessage::SendPacket(mut packet) => self.send(&mut packet).await?, } Ok(()) diff --git a/runtime/src/actor/client/fame.rs b/runtime/src/actor/client/fame.rs new file mode 100644 index 000000000..88b5e2efe --- /dev/null +++ b/runtime/src/actor/client/fame.rs @@ -0,0 +1,81 @@ +use super::ClientActor; +use crate::{ + db::spawn_db, + error::RuntimeError, + message::{ClientHandle, FieldMessage}, +}; +use db::fame::Refusal; +use net::packet::build::world::fame::{build_fame_response, FameResponse}; +use net::packet::build::world::stat::{self, StatUpdate}; +use net::FieldRequest; + +/// The most fame (or the least, negated) a character can have +/// (OdinMS-family limit). +const FAME_LIMIT: i16 = 30000; + +impl ClientActor { + /// This player raises (or drops) `target`'s fame: once a day, and once a + /// month for the same character. Their field hands it to `target`. + pub(super) async fn give_fame(&mut self, target: i32, up: bool) -> Result<(), RuntimeError> { + let giver = self.client_id; + let refusal = match spawn_db(move || db::fame::give(giver, target)).await? { + Ok(()) => None, + Err(Refusal::NotFound) => Some(FameResponse::NotFound), + Err(Refusal::NotToday) => Some(FameResponse::NotToday), + Err(Refusal::NotThisMonth) => Some(FameResponse::NotThisMonth), + }; + if let Some(refusal) = refusal { + return self.send(&mut build_fame_response(&refusal)?).await; + } + let giver_name = self.in_game()?.player.stats.name.clone(); + self.send_to_field(FieldMessage::FromClient { + from: giver, + request: FieldRequest::Fame { + target, + giver_name, + up, + }, + }) + .await + } + + /// The fame given `target` reached nobody: it doesn't count. + pub(super) async fn fame_missed(&mut self, target: i32) -> Result<(), RuntimeError> { + let giver = self.client_id; + spawn_db(move || db::fame::forget(giver, target)).await?; + self.send(&mut build_fame_response(&FameResponse::NotFound)?) + .await + } + + /// `giver_name` raised (or dropped) this player's fame: it changes by + /// one, and each hears of it. + pub(super) async fn take_fame( + &mut self, + giver: ClientHandle, + giver_name: String, + up: bool, + ) -> Result<(), RuntimeError> { + let player = &mut self.in_game_mut()?.player; + let fame = player.stats.fame + if up { 1 } else { -1 }; + if fame.abs() > FAME_LIMIT { + giver.send_packet(build_fame_response(&FameResponse::Failed)?); + return Ok(()); + } + player.stats.fame = fame; + player.mark_dirty(); + let target = player.stats.name.clone(); + let mut packets = vec![ + stat::build_stat_update(false, &[StatUpdate::Fame(fame)])?, + build_fame_response(&FameResponse::Received { + giver: giver_name, + up, + })?, + ]; + giver.send_packet(build_fame_response(&FameResponse::Gave { + target, + up, + fame, + })?); + self.send_all(&mut packets).await + } +} diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 3a118a531..f574a57d9 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -330,6 +330,11 @@ impl FieldActor { FieldRequest::CharInfo { character_id } => { self.handle_char_info(from, character_id) } + FieldRequest::Fame { + target, + giver_name, + up, + } => self.handle_fame(from, target, giver_name, up), }, FieldMessage::SpawnLoot { owner, @@ -392,6 +397,26 @@ impl FieldActor { } } + /// Hand `from`'s fame to `target`, if they are here. + fn handle_fame(&self, from: ClientId, target: i32, giver_name: String, up: bool) { + let Some(giver) = self.occupants.get(&from) else { + return; + }; + if let Some(occupant) = self + .occupants + .values() + .find(|occupant| occupant.avatar.id == target) + { + occupant.handle.send(ServerMessage::Famed { + giver: giver.handle.clone(), + giver_name, + up, + }); + } else { + giver.handle.send(ServerMessage::FameMissed { target }); + } + } + /// Put down what a player threw away where they stand, or show it /// falling and fading if it can't lie on the ground. fn handle_throw(&mut self, from: ClientId, loot: net::loot::Loot, vanish: bool) { @@ -1391,4 +1416,60 @@ mod tests { ); assert!(first_rx.try_recv().is_err()); } + + #[tokio::test] + async fn fame_reaches_its_target_only_on_the_givers_map() { + let field = spawn_field(); + let (first, mut first_rx) = client(1); + let (second, mut second_rx) = client(2); + for (handle, id) in [(first, 1), (second, 2)] { + field + .send(FieldMessage::Join { + handle, + avatar: avatar(id, 0, 0), + party: None, + }) + .await + .unwrap(); + } + next_opcode(&mut first_rx).await; + next_opcode(&mut second_rx).await; + + for target in [3, 2] { + field + .send(FieldMessage::FromClient { + from: 1, + request: FieldRequest::Fame { + target, + giver_name: "Al".into(), + up: true, + }, + }) + .await + .unwrap(); + } + match timeout(Duration::from_secs(1), second_rx.recv()) + .await + .unwrap() + .unwrap() + { + ServerMessage::Famed { + giver, + giver_name, + up, + } => { + assert_eq!((giver.client_id, giver_name.as_str(), up), (1, "Al", true)) + } + other => panic!("expected fame, got {other:?}"), + } + match timeout(Duration::from_secs(1), first_rx.recv()) + .await + .unwrap() + .unwrap() + { + ServerMessage::FameMissed { target } => assert_eq!(target, 3), + other => panic!("expected a miss, got {other:?}"), + } + assert!(first_rx.try_recv().is_err()); + } } diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 4f5d8da14..d147e609b 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -100,6 +100,18 @@ pub enum ServerMessage { CharInfoRequested { requester: ClientHandle, }, + /// `giver_name` raised (or dropped) this player's fame; tell `giver` how + /// it went. + Famed { + giver: ClientHandle, + giver_name: String, + up: bool, + }, + /// The fame this player gave `target` found nobody: they aren't on its + /// map. + FameMissed { + target: i32, + }, SendPacket(Packet), }