diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 0cc868728..d69b53049 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -606,6 +606,8 @@ add_executable(HeavenClient IO/UITypes/UITeleportRock.h IO/UITypes/UIMegaphone.cpp IO/UITypes/UIMegaphone.h + IO/UITypes/UIPetChooser.cpp + IO/UITypes/UIPetChooser.h IO/UITypes/UIItemMegaphone.cpp IO/UITypes/UIItemMegaphone.h IO/UITypes/UIInviteCard.cpp diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index 276f39c96..355e144f7 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -180,11 +180,15 @@ namespace ms } } - for (auto &pet : pets) + for (size_t i = 0; i < pets.size(); i++) { + PetLook &pet = pets[i]; + if (!pet.get_itemid()) continue; + pet.set_wear(look.get_pet_wear(i)); + // Another's pet goes where its owner's client says. if (pet.is_local()) { diff --git a/HeavenClient/Character/Look/CharLook.cpp b/HeavenClient/Character/Look/CharLook.cpp index f46e3cffe..7bbd3055d 100644 --- a/HeavenClient/Character/Look/CharLook.cpp +++ b/HeavenClient/Character/Look/CharLook.cpp @@ -19,6 +19,8 @@ #include "AttackStances.h" +#include "../Pets.h" + #include "../../Data/WeaponData.h" namespace ms @@ -32,7 +34,18 @@ namespace ms set_face(entry.faceid); for (auto &equip : entry.equips) + { + // Pet equipment is the pets', not the character's. + if (pets::is_equip_part(equip.first)) + { + if (int8_t index = pets::wear_owner(equip.first); index >= 0) + pet_wear[index] = equip.second; + + continue; + } + add_equip(equip.second); + } } CharLook::CharLook() @@ -658,6 +671,17 @@ namespace ms return face; } + int32_t CharLook::get_pet_wear(size_t index) const + { + return index < pet_wear.size() ? pet_wear[index] : 0; + } + + void CharLook::set_pet_wear(size_t index, int32_t itemid) + { + if (index < pet_wear.size()) + pet_wear[index] = itemid; + } + const CharEquips &CharLook::get_equips() const { return equips; diff --git a/HeavenClient/Character/Look/CharLook.h b/HeavenClient/Character/Look/CharLook.h index 31cacfa6b..77b4d4bae 100644 --- a/HeavenClient/Character/Look/CharLook.h +++ b/HeavenClient/Character/Look/CharLook.h @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #pragma once +#include #include #include @@ -59,6 +60,12 @@ namespace ms void add_equip(int32_t equipid); void remove_equip(EquipSlot::Id slot); + // The clothes the pet at `index` in the list wears (body parts 14, + // 30 and 38), 0 for none. + int32_t get_pet_wear(size_t index) const; + + void set_pet_wear(size_t index, int32_t itemid); + void attack(bool degenerate); // Change to a regular attack stance from get_attackstance. @@ -154,6 +161,7 @@ namespace ms const Hair *hair; const Face *face; CharEquips equips; + std::array pet_wear = {}; Randomizer randomizer; TimedBool alerted; diff --git a/HeavenClient/Character/Look/PetLook.cpp b/HeavenClient/Character/Look/PetLook.cpp index 5be66acb1..f78504339 100644 --- a/HeavenClient/Character/Look/PetLook.cpp +++ b/HeavenClient/Character/Look/PetLook.cpp @@ -22,6 +22,7 @@ #include "../../Constants.h" #include "../../Audio/Audio.h" #include "../../Data/PetData.h" +#include "../../Util/Misc.h" #include "../../Util/Randomizer.h" #include @@ -41,6 +42,40 @@ namespace ms return { type, pets::action(reaction.act, customs), text }; } + + // The animations of the stances, as Item.nx Pet/.img names them. + const char *STANCE_NAMES[PetLook::Stance::LENGTH] = { + "move", "stand0", "jump", "alert", "prone", "fly", "hang", "" + }; + + // The frames of `src`, by their number. + std::vector frames(nl::node src) + { + std::vector textures; + + for (nl::node sub : src) + { + if (sub.data_type() != nl::node::type::bitmap) + continue; + + int16_t index = string_conversion::or_default(sub.name(), -1); + + if (index < 0 || index >= 64) + continue; + + if (static_cast(index) >= textures.size()) + textures.resize(index + 1); + + textures[index] = sub; + } + + return textures; + } + + nl::node clothes(int32_t wear, int32_t itemid) + { + return nl::nx::character["PetEquip"]["0" + std::to_string(wear) + ".img"][std::to_string(itemid)]; + } } PetLook::Stance PetLook::stancebyvalue(uint8_t value) @@ -66,7 +101,7 @@ namespace ms shown({ static_cast(entry.position.x()), static_cast(entry.position.y()), 0.0, 0.0, entry.fh, entry.stance }), - action(-1), effecting(false), pending(false), still(0) + action(-1), wear(0), effecting(false), pending(false), still(0) { set_position(entry.position.x(), entry.position.y()); set_stance(entry.stance); @@ -78,13 +113,8 @@ namespace ms std::string strid = std::to_string(itemid); nl::node src = nl::nx::item["Pet"][strid + ".img"]; - animations[Stance::MOVE] = src["move"]; - animations[Stance::STAND] = src["stand0"]; - animations[Stance::JUMP] = src["jump"]; - animations[Stance::ALERT] = src["alert"]; - animations[Stance::PRONE] = src["prone"]; - animations[Stance::FLY] = src["fly"]; - animations[Stance::HANG] = src["hang"]; + for (size_t i = 0; i < Stance::WARP; i++) + animations[static_cast(i)] = src[STANCE_NAMES[i]]; nl::node effsrc = nl::nx::effect["PetEff.img"][strid]; @@ -92,18 +122,24 @@ namespace ms } PetLook::PetLook() : itemid(0), serial(0), local(false), stance(Stance::STAND), flip(false), - shown({ 0.0, 0.0, 0.0, 0.0, 0, 0 }), action(-1), effecting(false), pending(false), + shown({ 0.0, 0.0, 0.0, 0.0, 0, 0 }), action(-1), wear(0), effecting(false), pending(false), still(0) {} void PetLook::draw(double viewx, double viewy, float alpha) const { Point absp = phobj.get_absolute(viewx, viewy, alpha); + DrawArgument args = DrawArgument(absp, flip); if (action >= 0) - acting.draw(DrawArgument(absp, flip), alpha); - else - animations[stance].draw(DrawArgument(absp, flip), alpha); + { + acting.draw(args, alpha); + draw_dressing(acting_dressing, acting, args, alpha); + } else + { + animations[stance].draw(args, alpha); + draw_dressing(dressing[stance], animations[stance], args, alpha); + } if (effecting) effect.draw(DrawArgument(absp), alpha); @@ -288,6 +324,8 @@ namespace ms { acting = src; action = said.action; + acting_name = actname; + acting_dressing = wear ? frames(clothes(wear, itemid)[actname]) : std::vector(); } // A command's answer plays the pet's sound for its own actions. @@ -450,12 +488,51 @@ namespace ms return serial; } + Point PetLook::get_position() const + { + return phobj.get_position(); + } + + bool PetLook::is_on_ground() const + { + return phobj.onground; + } + + const PhysicsObject &PetLook::get_phobj() const + { + return phobj; + } + void PetLook::set_name(const std::string &n) { name = n; namelabel.change_text(name); } + void PetLook::set_wear(int32_t w) + { + if (wear == w) + return; + + wear = w; + + nl::node src = wear ? clothes(wear, itemid) : nl::node(); + + for (size_t i = 0; i < Stance::WARP; i++) + dressing[static_cast(i)] = frames(src[STANCE_NAMES[i]]); + + acting_dressing = action >= 0 ? frames(src[acting_name]) : std::vector(); + } + + void PetLook::draw_dressing(const std::vector &textures, const Animation &animation, + const DrawArgument &args, float alpha) const + { + size_t index = animation.get_frame_index(alpha); + + if (index < textures.size()) + textures[index].draw(args); + } + bool PetLook::is_local() const { return local; diff --git a/HeavenClient/Character/Look/PetLook.h b/HeavenClient/Character/Look/PetLook.h index 901340924..b01374573 100644 --- a/HeavenClient/Character/Look/PetLook.h +++ b/HeavenClient/Character/Look/PetLook.h @@ -114,9 +114,19 @@ namespace ms int64_t get_serial() const; + Point get_position() const; + + bool is_on_ground() const; + + const PhysicsObject &get_phobj() const; + // PET_NAMECHANGE (172): the pet answers to its new name. void set_name(const std::string &name); + // Dress it in the clothes `wear` (180xxxx) its owner's look gives + // its place in the list, or none for 0 (FUN_00703d15). + void set_wear(int32_t wear); + bool is_local() const; Stance get_stance() const; @@ -130,6 +140,10 @@ namespace ms // once it moves, and now and then act on its own. void update_acting(Point before); + // Draw the clothes' frame for the one `animation` shows. + void draw_dressing(const std::vector &textures, const Animation &animation, const DrawArgument &args, + float alpha) const; + int32_t itemid; std::string name; int64_t serial; @@ -151,6 +165,13 @@ namespace ms // pet moves. int16_t action; Animation acting; + std::string acting_name; + + // The clothes it wears, and their frames over each of its own + // (Character/PetEquip/.img//). + int32_t wear; + EnumMap> dressing; + std::vector acting_dressing; ChatBalloon balloon; Animation effect; bool effecting; diff --git a/HeavenClient/Character/Pets.h b/HeavenClient/Character/Pets.h index c78404708..78be87249 100644 --- a/HeavenClient/Character/Pets.h +++ b/HeavenClient/Character/Pets.h @@ -386,5 +386,206 @@ namespace ms return {}; } + + // Pet equipment goes on body parts 14 and 21 to 48, which the + // three pets in the list share out between them. + inline bool is_equip_part(int16_t part) + { + return part == 14 || (part >= 21 && part <= 48); + } + + // The parts pet equipment `itemid` goes on for each pet in the list + // (FUN_00460358), the same for all three for the HP and MP pouches. + // None for an item that isn't pet equipment. + // The ability items that change what a pet picks up (FUN_00705e2c). + constexpr int32_t MESO_MAGNET = 1812000; + constexpr int32_t ITEM_POUCH = 1812001; + constexpr int32_t SWEEPER = 1812004; + constexpr int32_t LONG_RANGE = 1812005; + constexpr int32_t MAGIC_SCALES = 1812006; + + inline std::optional> equip_parts(int32_t itemid) + { + switch (itemid) + { + case 1802100: + return std::array{ 21, 31, 39 }; + case 1812000: + return std::array{ 23, 34, 42 }; + case 1812001: + return std::array{ 22, 33, 41 }; + case 1812002: + return std::array{ 24, 24, 24 }; + case 1812003: + return std::array{ 25, 25, 25 }; + case 1812004: + return std::array{ 26, 35, 43 }; + case 1812005: + return std::array{ 27, 36, 44 }; + case 1812006: + return std::array{ 28, 37, 45 }; + case 1812007: + return std::array{ 46, 47, 48 }; + default: + break; + } + + switch (itemid / 10000) + { + case 180: + return std::array{ 14, 30, 38 }; + case 182: + return std::array{ 21, 31, 39 }; + case 183: + return std::array{ 29, 32, 40 }; + default: + return std::nullopt; + } + } + + // The pet in the list wearing what is on `part`, -1 for the shared + // pouches (FUN_00705e2c). + inline int8_t equip_owner(int16_t part) + { + if (part == 14 || (part >= 21 && part <= 29 && part != 24 && part != 25) || part == 46) + return 0; + + if ((part >= 30 && part <= 37) || part == 47) + return 1; + + if ((part >= 38 && part <= 45) || part == 48) + return 2; + + return -1; + } + + // The pet whose clothes are on `part` (14, 30 and 38), or -1. + inline int8_t wear_owner(int16_t part) + { + switch (part) + { + case 14: + return 0; + case 30: + return 1; + case 38: + return 2; + default: + return -1; + } + } + + // A pet in the list, as far as putting equipment on it goes. + struct Wearer + { + bool out = false; + // The item is clothing made for its kind + // (Character/PetEquip/.img/). + bool suits = false; + // Its kind shows a label ring's name tag, and a quote ring's + // balloon (Item.nx Pet/.img/info/nameTag, chatBalloon). + bool name_tag = false; + bool chat_balloon = false; + int16_t level = 0; + }; + + // StringPool 0x36f, 0x370 and 0x364. + constexpr const char *NO_PET = "You do not currently have a pet that is alive."; + constexpr const char *CANNOT_WEAR = "The pet is unable to wear the equipment."; + constexpr const char *STATS_TOO_LOW = "Your stats are too low to equip this item."; + + // Why equipment needing level `req_level` can't go on `part` of + // `pet`, or null if it can (FUN_004f1c2d). Clothes need a pet of + // the kind they were made for, at the level, and the rings a kind + // that shows them. The potion pouches, the Wing Boots, the Binocular + // and the Magic Scales need no pet at all. + inline const char *refuse_equip(int16_t part, int16_t req_level, const Wearer &pet) + { + switch (part) + { + case 14: + case 30: + case 38: + if (!pet.out || !pet.suits) + return CANNOT_WEAR; + + // FUN_004f2cee compares the pet's level, not the owner's. + if (pet.level < req_level) + return STATS_TOO_LOW; + + return nullptr; + case 21: + case 31: + case 39: + if (!pet.out) + return NO_PET; + + return pet.name_tag ? nullptr : CANNOT_WEAR; + case 29: + case 32: + case 40: + if (!pet.out) + return NO_PET; + + return pet.chat_balloon ? nullptr : CANNOT_WEAR; + case 22: + case 23: + case 46: + case 33: + case 34: + case 47: + case 41: + case 42: + case 48: + return pet.out ? nullptr : NO_PET; + default: + return nullptr; + } + } + + // A pet that pet equipment can go on: its place in the list, and + // the part. + struct EquipChoice + { + uint8_t pet; + int16_t part; + + bool operator==(const EquipChoice &other) const + { + return pet == other.pet && part == other.part; + } + }; + + // The pets a double-click in the item window can put pet equipment + // `itemid` on (FUN_004efd25), given the three places in the list. + // With one pet out or none, and for the potion pouches, it is the + // first pet's part whether it can wear it or not. Otherwise clothes + // go on the pets of their kind, ability items on any, and only the + // Pet Label Ring and the Pet Quote Ring of the rings may choose. + inline std::vector equip_choices(int32_t itemid, const std::array &pets) + { + std::optional> parts = equip_parts(itemid); + + if (!parts) + return {}; + + int16_t first = (*parts)[0]; + + if (first == 24 || first == 25 || !pets[1].out) + return { { 0, first } }; + + std::vector choices; + + for (uint8_t i = 0; i < pets.size(); i++) + { + bool fits = itemid / 10000 == 180 ? pets[i].suits + : itemid / 10000 == 181 || itemid == 1822000 || itemid == 1832000; + + if (pets[i].out && fits) + choices.push_back({ i, (*parts)[i] }); + } + + return choices; + } } } diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 47e50f82c..54c473cfb 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -198,6 +198,16 @@ namespace ms if (part == EquipSlot::Id::NONE) return; + // The pets wear their own equipment, and the look carries only + // their clothes. + if (pets::is_equip_part(part)) + { + if (int8_t index = pets::wear_owner(part); index >= 0) + look.set_pet_wear(index, inventory.get_item_id(InventoryType::Id::EQUIPPED, inventory.shown_equip(part))); + + return; + } + // The look wears a cash item over the regular one, but has no cash // weapons (v83 draws them over the regular weapon's stances). int16_t shown = part == EquipSlot::Id::WEAPON ? part : inventory.shown_equip(part); diff --git a/HeavenClient/Data/ItemData.cpp b/HeavenClient/Data/ItemData.cpp index 2181a0247..59a43aee2 100644 --- a/HeavenClient/Data/ItemData.cpp +++ b/HeavenClient/Data/ItemData.cpp @@ -131,7 +131,7 @@ namespace ms return categorynames[index]; else if (index >= 30 && index <= 70) return "Weapon"; - else if (index == 80) + else if (index >= 80 && index <= 83) return "PetEquip"; else return ""; diff --git a/HeavenClient/Data/PetData.cpp b/HeavenClient/Data/PetData.cpp index ea4fd5967..e5b77470f 100644 --- a/HeavenClient/Data/PetData.cpp +++ b/HeavenClient/Data/PetData.cpp @@ -54,6 +54,7 @@ namespace ms kind = nl::nx::string["Pet.img"][strid]["name"].get_string(); balloon = static_cast(src["info"]["chatBalloon"].get_integer()); + name_tag = static_cast(src["info"]["nameTag"].get_integer()); nl::node interact = src["interact"]; @@ -95,6 +96,11 @@ namespace ms return balloon; } + int8_t PetData::get_name_tag() const + { + return name_tag; + } + const std::vector &PetData::get_commands() const { return commands; diff --git a/HeavenClient/Data/PetData.h b/HeavenClient/Data/PetData.h index 5462cee83..a397e26ef 100644 --- a/HeavenClient/Data/PetData.h +++ b/HeavenClient/Data/PetData.h @@ -37,6 +37,10 @@ namespace ms // The style of its chat balloon when called by name (info/chatBalloon). int8_t get_balloon() const; + // The style of its name tag with a label ring on (info/nameTag), 0 + // for a kind that shows none. + int8_t get_name_tag() const; + // Its commands, by the index sent for them. const std::vector &get_commands() const; @@ -57,6 +61,7 @@ namespace ms std::string kind; int8_t balloon; + int8_t name_tag; std::vector commands; std::vector food; std::vector idle; diff --git a/HeavenClient/Gameplay/MapleMap/Drop.cpp b/HeavenClient/Gameplay/MapleMap/Drop.cpp index da587a976..4ae5ce95c 100644 --- a/HeavenClient/Gameplay/MapleMap/Drop.cpp +++ b/HeavenClient/Gameplay/MapleMap/Drop.cpp @@ -106,10 +106,12 @@ namespace ms elapsed = 0; age = 0; sincetry = pickup::RETRY; + sincepet = pickup::RETRY; delay = std::max(dly, 0); flighttime = flight_time(start.y(), dest.y(), pickuptype == EXPLOSIVE); phase = 0.0; looter = nullptr; + topet = false; if (mode == 2) { @@ -129,6 +131,7 @@ namespace ms elapsed += Constants::TIMESTEP; age += Constants::TIMESTEP; sincetry = std::min(sincetry + Constants::TIMESTEP, pickup::RETRY); + sincepet = std::min(sincepet + Constants::TIMESTEP, pickup::RETRY); if (state == Drop::State::WAITING) { @@ -222,7 +225,7 @@ namespace ms double t = elapsed; double targetx = looter->get_x(); - double targety = looter->get_y() - 4 - LOOTERHALFHEIGHT; + double targety = topet ? looter->get_y() : looter->get_y() - 4 - LOOTERHALFHEIGHT; double x = ((PICKUPTIME - t) * pickupstart.x() + t * targetx) / PICKUPTIME; double y = ((PICKUPTIME - t) * pickupstart.y() + t * targety) / PICKUPTIME - PICKUPARC + PICKUPARC * (t - PICKUPTIME / 2) * (t - PICKUPTIME / 2) / (PICKUPTIME * PICKUPTIME / 4); @@ -268,6 +271,7 @@ namespace ms break; default: looter = lt; + topet = type == 5; pick_up(); break; } @@ -303,4 +307,31 @@ namespace ms { sincetry = 0; } + + bool Drop::pet_lootable(Point feet, int32_t self, int32_t party, bool mesos, bool items, + bool others) const + { + // Pets leave what players dropped, and whatever their equipment + // doesn't pick up. + if (playerdrop || !(get_itemid() ? items : mesos)) + return false; + + if (pickup::pet_owned_by_other(pickuptype, owner, self, party, age - delay, others)) + return false; + + if (state != Drop::State::FLOATING || sincepet < pickup::RETRY) + return false; + + return pickup::in_reach(feet, spot); + } + + void Drop::try_pet_loot() + { + sincepet = 0; + } + + int32_t Drop::get_itemid() const + { + return 0; + } } diff --git a/HeavenClient/Gameplay/MapleMap/Drop.h b/HeavenClient/Gameplay/MapleMap/Drop.h index a29b5c90c..6e6b8fb12 100644 --- a/HeavenClient/Gameplay/MapleMap/Drop.h +++ b/HeavenClient/Gameplay/MapleMap/Drop.h @@ -40,6 +40,17 @@ namespace ms // Note a pickup request, so the drop isn't tried again for a while. void try_loot(); + // Whether a pet at `feet` could take the drop now, if it takes mesos, + // items and others' drops as given (FUN_0050483a). + bool pet_lootable(Point feet, int32_t self, int32_t party, bool mesos, bool items, + bool others) const; + + // Note a pet's request, so no pet tries the drop again for a while. + void try_pet_loot(); + + // The item, or 0 for mesos. + virtual int32_t get_itemid() const; + protected: Drop(int32_t oid, int32_t owner, Point start, Point dest, int8_t type, int8_t mode, int16_t delay, bool playerdrop, bool spins); @@ -74,6 +85,8 @@ namespace ms bool spins; const PhysicsObject *looter; + // It flies to a pet's feet rather than to a character's middle. + bool topet; State state; Point start; @@ -86,6 +99,7 @@ namespace ms // request for it. int32_t age; int32_t sincetry; + int32_t sincepet; int32_t delay; int32_t flighttime; double phase; diff --git a/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp b/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp index 128f91330..cd6bfb19d 100644 --- a/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp +++ b/HeavenClient/Gameplay/MapleMap/ItemDrop.cpp @@ -32,4 +32,9 @@ namespace ms Point absp = centred(phobj.get_absolute(viewx, viewy, alpha), icon.get_origin(), icon.get_dimensions()); icon.draw({angle.get(alpha), absp, opacity.get(alpha)}); } + + int32_t ItemDrop::get_itemid() const + { + return itemid; + } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/MapleMap/ItemDrop.h b/HeavenClient/Gameplay/MapleMap/ItemDrop.h index aa11b08e2..74913ba78 100644 --- a/HeavenClient/Gameplay/MapleMap/ItemDrop.h +++ b/HeavenClient/Gameplay/MapleMap/ItemDrop.h @@ -33,6 +33,8 @@ namespace ms void draw(double viewx, double viewy, float alpha) const override; + int32_t get_itemid() const override; + private: const Texture &icon; int32_t itemid; diff --git a/HeavenClient/Gameplay/MapleMap/MapDrops.cpp b/HeavenClient/Gameplay/MapleMap/MapDrops.cpp index a51913772..8f7182e2d 100644 --- a/HeavenClient/Gameplay/MapleMap/MapDrops.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapDrops.cpp @@ -121,4 +121,22 @@ namespace ms return {0, {}}; } + + int32_t MapDrops::find_pet_loot_at(Point petpos, int32_t self, int32_t party, bool mesos, bool items, + bool others) + { + for (auto &mmo : drops) + { + Optional drop = mmo.second.get(); + + if (drop && drop->pet_lootable(petpos, self, party, mesos, items, others)) + { + drop->try_pet_loot(); + + return mmo.first; + } + } + + return 0; + } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/MapleMap/MapDrops.h b/HeavenClient/Gameplay/MapleMap/MapDrops.h index 607e621b6..aea9024f0 100644 --- a/HeavenClient/Gameplay/MapleMap/MapDrops.h +++ b/HeavenClient/Gameplay/MapleMap/MapDrops.h @@ -59,6 +59,12 @@ namespace ms Loot find_loot_at(Point playerpos, int32_t self, int32_t party); + // The first drop a pet at `petpos` would reach for, if it takes + // mesos, items and others' drops as given, or 0 for none + // (FUN_0050483a). + int32_t find_pet_loot_at(Point petpos, int32_t self, int32_t party, bool mesos, bool items, + bool others); + private: MapObjects drops; diff --git a/HeavenClient/Gameplay/MapleMap/Pickup.h b/HeavenClient/Gameplay/MapleMap/Pickup.h index 79edd7cc5..8026f5542 100644 --- a/HeavenClient/Gameplay/MapleMap/Pickup.h +++ b/HeavenClient/Gameplay/MapleMap/Pickup.h @@ -34,5 +34,17 @@ namespace ms return (type == 0 && owner != self) || (type == 1 && owner != party); } + + // Whether a pet leaves a drop to someone else (FUN_0050483a). Unlike + // its owner it never takes another's drop, unless it wears Magic + // Scales (`others`), and then only once 15 s have passed. + inline bool pet_owned_by_other(int8_t type, int32_t owner, int32_t self, int32_t party, int32_t age, + bool others) + { + if (others && age > OWNERSHIP) + return false; + + return (type == 0 && owner != self) || (type == 1 && owner != party); + } } } diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index ece3f1a04..71d35fac0 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -18,6 +18,7 @@ #include "Stage.h" #include "../Configuration.h" +#include "../Character/Pets.h" #include "QuestConversation.h" #include "Timer.h" #include "MapleMap/CharClick.h" @@ -283,6 +284,7 @@ namespace ms // The player's summons follow where the player now is. summons.update(physics, player.get_position(), player.is_climbing()); + check_pet_loot(); portals.update(player.get_position()); check_touch_portals(drawn); @@ -650,6 +652,45 @@ namespace ms player.request(PickupItemPacket(loot.first, loot.second), ExclRequest::PICKUP_WAIT); } + void Stage::check_pet_loot() + { + // Pets don't loot for a dead owner, or in the Extra Frosty Snow Zone + // (FUN_0070403d). + if (player.is_dead() || mapid == 209080000) + return; + + const Inventory &inventory = player.get_inventory(); + // Whether the pet `index` wears `itemid` (FUN_00705e2c). + auto wears = [&](uint8_t index, int32_t itemid) { + auto part = static_cast((*pets::equip_parts(itemid))[index]); + + return inventory.get_item_id(InventoryType::Id::EQUIPPED, inventory.shown_equip(part)) == itemid; + }; + + for (uint8_t index = 0; index < 3; index++) + { + Optional pet = player.get_pet(index); + + if (!pet || !pet->is_on_ground()) + continue; + + bool mesos = wears(index, pets::MESO_MAGNET); + bool items = wears(index, pets::ITEM_POUCH); + + if (!mesos && !items) + continue; + + bool others = wears(index, pets::MAGIC_SCALES); + Point position = pet->get_position(); + + if (int32_t oid = drops.find_pet_loot_at(position, player.get_oid(), player.get_party().get_id(), mesos, + items, others)) + PetLootPacket(pet->get_serial(), position, oid, others, wears(index, pets::SWEEPER), + wears(index, pets::LONG_RANGE)) + .dispatch(); + } + } + void Stage::send_key(KeyType::Id type, int32_t action, bool down) { if (state != State::ACTIVE || !playable || player.is_dead()) diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index d8e4f3d79..bbb45bead 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -217,6 +217,10 @@ namespace ms void check_drops(); + // Each pet out with a Meso Magnet or an Item Pouch reaches for a + // drop at its feet (FUN_00705c5d). + void check_pet_loot(); + // Fear's darkness, all but the player's narrowed sight (FUN_0053868d). void draw_fear(double viewx, double viewy, float alpha) const; diff --git a/HeavenClient/Graphics/Animation.cpp b/HeavenClient/Graphics/Animation.cpp index aadbf6ffd..79cb77e3d 100644 --- a/HeavenClient/Graphics/Animation.cpp +++ b/HeavenClient/Graphics/Animation.cpp @@ -334,6 +334,11 @@ namespace ms return get_frame().get_bounds(); } + int16_t Animation::get_frame_index(float alpha) const + { + return frame.get(alpha); + } + const Frame &Animation::get_frame() const { return frames[frame.get()]; diff --git a/HeavenClient/Graphics/Animation.h b/HeavenClient/Graphics/Animation.h index e35cc4e21..b38f4132e 100644 --- a/HeavenClient/Graphics/Animation.h +++ b/HeavenClient/Graphics/Animation.h @@ -89,6 +89,9 @@ namespace ms uint16_t get_delay(int16_t frame) const; + // The frame drawn at `alpha` between updates. + int16_t get_frame_index(float alpha) const; + uint16_t getdelayuntil(int16_t frame) const; // How long one play through every frame takes. diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index b7e6a8763..4725beab1 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -352,6 +352,7 @@ namespace ms types.emplace_back(UIElement::Type::TELEPORTROCK); types.emplace_back(UIElement::Type::MEGAPHONE); types.emplace_back(UIElement::Type::ITEMMEGAPHONE); + types.emplace_back(UIElement::Type::PETCHOOSER); } else if (enter) { @@ -363,6 +364,7 @@ namespace ms types.emplace_back(UIElement::Type::ENTERNUMBER); types.emplace_back(UIElement::Type::TELEPORTROCK); types.emplace_back(UIElement::Type::MEGAPHONE); + types.emplace_back(UIElement::Type::PETCHOOSER); } else if (tab) { // Game diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index cf322c978..4ace4c85b 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -88,6 +88,7 @@ namespace ms TELEPORTROCK, MEGAPHONE, ITEMMEGAPHONE, + PETCHOOSER, NUM_TYPES }; diff --git a/HeavenClient/IO/UITypes/UIEquipInventory.cpp b/HeavenClient/IO/UITypes/UIEquipInventory.cpp index 93284e628..357c20b46 100644 --- a/HeavenClient/IO/UITypes/UIEquipInventory.cpp +++ b/HeavenClient/IO/UITypes/UIEquipInventory.cpp @@ -31,6 +31,8 @@ #include +#include + namespace ms { namespace @@ -43,12 +45,13 @@ namespace ms constexpr int16_t SLOT_SIZE = 32; // Where an icon is drawn from, relative to its cell. constexpr Point ICON = Point(0, 0); - constexpr Point PET_SHOW = Point(115, 279); - // The pet panel hangs off the right edge, flush with the bottom. - constexpr Point PET_PANEL = Point(173, 123); - constexpr Point PET_TAB = Point(9, 151); + constexpr Point PET_SHOW = Point(102, 280); + // The pet panel hangs off the right edge, flush with the bottom + // (FUN_007fe02e), with a tab for each pet (FUN_007ffe73). + constexpr Point PET_PANEL = Point(172, 123); + constexpr Point PET_TAB = Point(10, 151); constexpr int16_t PET_TAB_PITCH = 34; - constexpr Point PET_HIDE = Point(154, 159); + constexpr Point PET_HIDE = Point(155, 159); struct Cell { @@ -84,6 +87,43 @@ namespace ms constexpr Point MOB_EQUIP = Point(2, 6); + struct PetCell + { + // The part it holds on each pet's tab. + std::array parts; + Point position; + }; + + // The pet panel's cells (0x00be2260), from its top left. + constexpr PetCell PET_CELLS[] = { + { { 24, 24, 24 }, { 13, 11 } }, + { { 25, 25, 25 }, { 79, 11 } }, + { { 22, 33, 41 }, { 13, 44 } }, + { { 23, 34, 42 }, { 46, 44 } }, + { { 46, 47, 48 }, { 13, 77 } }, + { { 28, 37, 45 }, { 46, 77 } }, + { { 26, 35, 43 }, { 79, 77 } }, + { { 14, 30, 38 }, { 112, 77 } }, + { { 27, 36, 44 }, { 13, 110 } }, + { { 21, 31, 39 }, { 79, 110 } }, + { { 29, 32, 40 }, { 112, 110 } } + }; + + // The first pet's clothes, label and quote ring cells, and the auto + // potions' slots beside the HP and MP pouches. + constexpr Point PET_WEAR = Point(112, 77); + constexpr Point PET_LABEL = Point(79, 110); + constexpr Point PET_QUOTE = Point(112, 110); + constexpr Point HP_POTION = Point(46, 11); + constexpr Point MP_POTION = Point(112, 11); + constexpr int16_t HP_POUCH = 24; + constexpr int16_t MP_POUCH = 25; + + EquipSlot::Id part_slot(int16_t part) + { + return static_cast(part); + } + constexpr Point cell_position(int16_t column, int16_t row) { return SLOT + Point(column * SLOT_PITCH, row * SLOT_PITCH); @@ -94,11 +134,15 @@ namespace ms } UIEquipInventory::UIEquipInventory(const Inventory &invent) - : UIDragElement(), inventory(invent), pet_enabled(false) + : UIDragElement(), inventory(invent), pet_enabled(false), pet_tab(0) { for (auto cell : CELLS) iconpositions[cell.slot] = cell_position(cell.column, cell.row); + for (const PetCell &cell : PET_CELLS) + for (int16_t part : cell.parts) + iconpositions[part_slot(part)] = PET_PANEL + cell.position; + nl::node Equip = NxFiles::ui_classic["UIWindow.img"]["Equip"]; backgrnd = Equip["backgrnd"]; @@ -114,15 +158,13 @@ namespace ms buttons[Buttons::BT_PET_HIDE] = std::make_unique(hide, PET_PANEL + PET_HIDE + Texture(hide["normal"]["0"]).get_origin()); - // Each tab shows a pet's equipment, which RustMS doesn't have yet, so they - // stay disabled. + // Each tab shows a pet's equipment, and is there while the pet is out. for (uint16_t i = 0; i < 3; i++) { - nl::node tab = Equip["BtPet" + std::to_string(i + 1)]; + nl::node tab = NxFiles::ui_classic["UIWindow.img"]["UserInfo"]["BtPet" + std::to_string(i + 1)]; Point pos = PET_PANEL + PET_TAB + Point(PET_TAB_PITCH * i, 0); buttons[Buttons::BT_PET1 + i] = std::make_unique(tab, pos + Texture(tab["normal"]["0"]).get_origin()); - buttons[Buttons::BT_PET1 + i]->set_state(Button::State::DISABLED); } Point bg_dimensions = backgrnd.get_dimensions(); @@ -137,10 +179,33 @@ namespace ms { UIElement::draw_sprites(alpha); - backgrnd.draw(position); - + // The panel slides out from under the window (FUN_00801474). if (pet_enabled) - pet.draw(position + PET_PANEL); + { + Point panel = position + PET_PANEL; + + pet.draw(panel); + + if (!has_pet(0)) + { + unavailable.draw(panel + PET_WEAR); + unavailable.draw(panel + PET_LABEL); + unavailable.draw(panel + PET_QUOTE); + } + + // No pouch, no auto potion. + if (!has_pet(0) || !is_worn(HP_POUCH)) + unavailable.draw(panel + HP_POTION); + + if (!has_pet(0) || !is_worn(MP_POUCH)) + unavailable.draw(panel + MP_POTION); + + for (const PetCell &cell : PET_CELLS) + if (auto &icon = icons[part_slot(cell.parts[pet_tab])]) + icon->draw(panel + cell.position + ICON); + } + + backgrnd.draw(position); for (auto cell : CELLS) if (is_unavailable(cell.slot)) @@ -169,6 +234,12 @@ namespace ms case Buttons::BT_PET_HIDE: show_pet(false); break; + case Buttons::BT_PET1: + case Buttons::BT_PET2: + case Buttons::BT_PET3: + pet_tab = id - Buttons::BT_PET1; + clear_tooltip(); + break; default: break; } @@ -176,6 +247,32 @@ namespace ms return Button::State::NORMAL; } + void UIEquipInventory::update() + { + UIElement::update(); + + for (uint8_t i = 0; i < 3; i++) + { + Button &tab = *buttons[Buttons::BT_PET1 + i]; + bool out = has_pet(i); + + if (!out) + tab.set_state(Button::State::DISABLED); + else if (tab.get_state() == Button::State::DISABLED) + tab.set_state(Button::State::NORMAL); + } + } + + bool UIEquipInventory::has_pet(uint8_t index) const + { + return static_cast(Stage::get().get_player().get_pet(index)); + } + + bool UIEquipInventory::is_worn(int16_t part) const + { + return inventory.get_item_id(InventoryType::Id::EQUIPPED, inventory.shown_equip(part_slot(part))) != 0; + } + void UIEquipInventory::show_pet(bool show) { pet_enabled = show; @@ -244,6 +341,10 @@ namespace ms for (auto cell : CELLS) update_slot(cell.slot); + + for (const PetCell &cell : PET_CELLS) + for (int16_t part : cell.parts) + update_slot(part_slot(part)); } Cursor::State UIEquipInventory::send_cursor(bool pressed, Point cursorpos) @@ -374,6 +475,18 @@ namespace ms return cell.slot; } + if (!pet_enabled) + return EquipSlot::Id::NONE; + + for (const PetCell &cell : PET_CELLS) + { + Point cellpos = position + PET_PANEL + cell.position; + Rectangle cellrect = Rectangle(cellpos, cellpos + Point(SLOT_SIZE, SLOT_SIZE)); + + if (cellrect.contains(cursorpos)) + return part_slot(cell.parts[pet_tab]); + } + return EquipSlot::Id::NONE; } diff --git a/HeavenClient/IO/UITypes/UIEquipInventory.h b/HeavenClient/IO/UITypes/UIEquipInventory.h index 5b42ce749..3a5f5507c 100644 --- a/HeavenClient/IO/UITypes/UIEquipInventory.h +++ b/HeavenClient/IO/UITypes/UIEquipInventory.h @@ -37,6 +37,8 @@ namespace ms void draw(float inter) const override; + void update() override; + void toggle_active() override; bool send_icon(const Icon& icon, Point position) override; @@ -63,6 +65,9 @@ namespace ms bool is_unavailable(EquipSlot::Id slot) const; EquipSlot::Id slot_by_position(Point position) const; void show_pet(bool show); + // Whether the pet at `index` in the list is out. + bool has_pet(uint8_t index) const; + bool is_worn(int16_t part) const; class EquipIcon : public Icon::Type { @@ -101,5 +106,7 @@ namespace ms ColorBox unavailable; bool pet_enabled; + // The pet whose equipment the panel shows. + uint8_t pet_tab; }; } diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 12e4054db..b4d7c9b03 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -22,6 +22,7 @@ #include "UIMegaphone.h" #include "UINotice.h" #include "UIPersonalShop.h" +#include "UIPetChooser.h" #include "UITeleportRock.h" #include "UITradingRoom.h" @@ -37,6 +38,7 @@ #include "../../Character/Inventory/ScrollRules.h" #include "../../Data/CurseData.h" #include "../../Data/EquipData.h" +#include "../../Data/PetData.h" #include "../../Gameplay/Stage.h" #include "../../Util/Chalkboard.h" #include "../../Util/Megaphones.h" @@ -49,6 +51,8 @@ #include +#include + namespace ms { namespace @@ -675,11 +679,15 @@ namespace ms switch (tab) { case InventoryType::Id::EQUIP: - if (can_wear_equip(slot)) + if (pets::equip_parts(item_id)) + { + wear_pet_equip(slot, item_id); + } else if (can_wear_equip(slot)) { int16_t worn = inventory.equip_position(slot, inventory.find_equipslot(item_id)); Stage::get().get_player().request(EquipItemPacket(slot, worn), ExclRequest::WAIT); + Sound(Sound::Name::DRAGEND).play(); } break; @@ -969,13 +977,11 @@ namespace ms return first_slot() + (full_enabled ? MAXFULLSLOTS : MAXSLOTS) - 1; } - bool UIItemInventory::can_wear_equip(int16_t slot) const + bool UIItemInventory::can_change_equips() const { const Player &player = Stage::get().get_player(); - const CharStats &stats = player.get_stats(); - const CharLook &look = player.get_look(); - if (look.get_alerted()) + if (player.get_look().get_alerted()) { UI::get().emplace(CATCH_BREATH, [](bool) {}); @@ -989,6 +995,17 @@ namespace ms return false; } + return true; + } + + bool UIItemInventory::can_wear_equip(int16_t slot) const + { + const Player &player = Stage::get().get_player(); + const CharStats &stats = player.get_stats(); + + if (!can_change_equips()) + return false; + const int32_t item_id = inventory.get_item_id(InventoryType::Id::EQUIP, slot); const EquipData &equipdata = EquipData::get(item_id); const ItemData &itemdata = equipdata.get_itemdata(); @@ -1060,6 +1077,99 @@ namespace ms return true; } + bool UIItemInventory::can_wear_pet_equip(int16_t slot, int16_t part) const + { + if (!can_change_equips()) + return false; + + int32_t item_id = inventory.get_item_id(InventoryType::Id::EQUIP, slot); + int8_t owner = pets::equip_owner(part); + pets::Wearer wearer = owner >= 0 ? pet_wearer(owner, item_id) : pets::Wearer(); + int16_t req_level = EquipData::get(item_id).get_reqstat(MapleStat::Id::LEVEL); + + if (const char *refusal = pets::refuse_equip(part, req_level, wearer)) + { + UI::get().emplace(refusal, [](bool) + {}); + return false; + } + + return true; + } + + pets::Wearer UIItemInventory::pet_wearer(uint8_t index, int32_t item_id) const + { + pets::Wearer wearer; + Optional pet = Stage::get().get_player().get_pet(index); + + if (!pet) + return wearer; + + int32_t kind = pet->get_itemid(); + const PetData &data = PetData::get(kind); + + wearer.out = true; + // Clothes are drawn for the kinds they were made for. + wearer.suits = item_id / 10000 == 180 + && nl::nx::character["PetEquip"]["0" + std::to_string(item_id) + ".img"][std::to_string(kind)]; + wearer.name_tag = data.get_name_tag() != 0; + wearer.chat_balloon = data.get_balloon() != 0; + wearer.level = inventory.get_pet_level(pet->get_serial()); + + return wearer; + } + + void UIItemInventory::wear_pet_equip(int16_t slot, int32_t item_id) const + { + Player &player = Stage::get().get_player(); + std::array wearers; + + for (uint8_t i = 0; i < wearers.size(); i++) + wearers[i] = pet_wearer(i, item_id); + + std::vector choices = pets::equip_choices(item_id, wearers); + + auto wear = [slot, item_id](int16_t part) + { + auto window = UI::get().get_element(); + + // The item may have moved while the pet was chosen. + if (!window || window->inventory.get_item_id(InventoryType::Id::EQUIP, slot) != item_id + || !window->can_wear_pet_equip(slot, part)) + return; + + int16_t worn = window->inventory.equip_position(slot, static_cast(part)); + + Stage::get().get_player().request(EquipItemPacket(slot, worn), ExclRequest::WAIT); + Sound(Sound::Name::DRAGEND).play(); + }; + + if (choices.size() == 1) + { + wear(choices[0].part); + } else if (choices.size() > 1) + { + std::vector shown; + + for (const pets::EquipChoice &choice : choices) + { + Optional look = player.get_pet(choice.pet); + Optional pet = inventory.get_pet(look->get_serial()); + + if (pet) + shown.push_back({ look->get_itemid(), pet->get_name(), pet->get_level(), + static_cast(pet->get_closeness()) }); + else + shown.push_back({ look->get_itemid(), "", 0, 0 }); + } + + UI::get().emplace(std::move(shown), [wear, choices](size_t index) + { + wear(choices[index].part); + }); + } + } + int16_t UIItemInventory::slot_by_position(Point cursorpos) const { Point offset = cursorpos - (full_enabled ? FULL_SLOT : SLOT); @@ -1272,7 +1382,18 @@ namespace ms switch (sourcetab) { case InventoryType::Id::EQUIP: - if (eqsource == eqslot) + if (std::optional> parts = pets::equip_parts(item_id)) + { + // Pet equipment goes on the cell it was dropped on, of + // the pet whose tab is open (FUN_004f1b67). + if (std::find(parts->begin(), parts->end(), eqslot) != parts->end() + && parent.can_wear_pet_equip(source, eqslot)) + { + int16_t worn = Stage::get().get_player().get_inventory().equip_position(source, eqslot); + + Stage::get().get_player().request(EquipItemPacket(source, worn), ExclRequest::WAIT); + } + } else if (eqsource == eqslot) { int16_t worn = Stage::get().get_player().get_inventory().equip_position(source, eqslot); diff --git a/HeavenClient/IO/UITypes/UIItemInventory.h b/HeavenClient/IO/UITypes/UIItemInventory.h index 6dab7707c..09f04a56a 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.h +++ b/HeavenClient/IO/UITypes/UIItemInventory.h @@ -24,6 +24,8 @@ #include "../Components/ClassicTabs.h" #include "../Components/Slider.h" +#include "../../Character/Pets.h" + namespace ms { // The Item inventory @@ -81,8 +83,22 @@ namespace ms bool is_visible(int16_t slot) const; + // Whether the player may change equipment now, with a notice if not. + bool can_change_equips() const; + bool can_wear_equip(int16_t slot) const; + // Whether the pet equipment in `slot` may go on `part`, with a + // notice if not (FUN_004f1c2d). + bool can_wear_pet_equip(int16_t slot, int16_t part) const; + + // The pet at `index` in the list, as putting `item_id` on it goes. + pets::Wearer pet_wearer(uint8_t index, int32_t item_id) const; + + // Put the pet equipment `item_id` in `slot` on a pet it can go on, + // asking which when there are several (FUN_004efd25). + void wear_pet_equip(int16_t slot, int32_t item_id) const; + int16_t first_slot() const; int16_t last_slot() const; diff --git a/HeavenClient/IO/UITypes/UIPetChooser.cpp b/HeavenClient/IO/UITypes/UIPetChooser.cpp new file mode 100644 index 000000000..66f4346ff --- /dev/null +++ b/HeavenClient/IO/UITypes/UIPetChooser.cpp @@ -0,0 +1,159 @@ +#include "UIPetChooser.h" + +#include "../KeyAction.h" + +#include "../Components/MapleButton.h" + +#include "../../Constants.h" +#include "../../Util/NxFiles.h" + +#include + +#include + +namespace ms +{ + namespace + { + // FUN_009a3cae and FUN_009a3ced; FUN_009a3e38 centres it on the screen. + constexpr int16_t WIDTH = 419, HEIGHT = 297; + // The rest is from the window's top left (FUN_009a9b25 and FUN_009a3e9f). 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 PREV_AT(15, 191); + const Point NEXT_AT(341, 191); + const Point OK_AT(314, 272); + const Point CANCEL_AT(363, 272); + const Point EXIT_AT(9, 272); + const Point NAME_TAG_AT(123, 220); + const Point PET_AT(209, 218); + constexpr int16_t NAME_X = 210, NAME_Y = 224; + constexpr int16_t LEVEL_X = 194, CLOSENESS_X = 280, NUMBERS_Y = 243; + // The caption's box (FUN_009acc20): its text starts 120 across, and is centred on 100 rows from 28 down + // when it fits. + constexpr int16_t CAPTION_X = 120, CAPTION_TOP = 28, CAPTION_ROWS = 100; + constexpr uint16_t CAPTION_WIDTH = 280; + + // StringPool 0x182. + constexpr const char *CAPTION = "Please select the pet you would like to put the pet equipment on."; + } + + UIPetChooser::UIPetChooser(std::vector p, std::function oc) + : UIElement(Point(), Point(WIDTH, HEIGHT)), pets(std::move(p)), onchoose(std::move(oc)), + shown(0) + { + nl::node window = NxFiles::ui_classic["UIWindow.img"]; + nl::node avatar = window["UtilDlgEx_Avatar"]; + nl::node basic = NxFiles::ui_classic["Basic.img"]; + + sprites.emplace_back(window["UtilDlgEx_Pet"]["backgrnd"]); + name_tag = avatar["nameTag"]; + + buttons[Buttons::PREV] = std::make_unique(avatar["BtPrev"], PREV_AT); + buttons[Buttons::NEXT] = std::make_unique(avatar["BtNext"], NEXT_AT); + buttons[Buttons::OK] = std::make_unique(basic["BtOK2"], OK_AT); + buttons[Buttons::CANCEL] = std::make_unique(basic["BtCancel2"], CANCEL_AT); + buttons[Buttons::EXIT] = std::make_unique(avatar["BtExit"], EXIT_AT); + + caption = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, CAPTION, CAPTION_WIDTH); + name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + level = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + closeness = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + + show(0); + update_screen(Constants::Constants::get().get_viewwidth(), Constants::Constants::get().get_viewheight()); + } + + void UIPetChooser::draw(float alpha) const + { + UIElement::draw_sprites(alpha); + + int16_t height = caption.height(); + int16_t top = height <= CAPTION_ROWS ? CAPTION_TOP + (CAPTION_ROWS - 6 - height) / 2 : CAPTION_TOP - 6; + + caption.draw(position + Point(CAPTION_X, top + 2 - TEXT_RAISE)); + name_tag.draw(position + NAME_TAG_AT); + name.draw(position + Point(NAME_X, NAME_Y - TEXT_RAISE)); + level.draw(position + Point(LEVEL_X, NUMBERS_Y - TEXT_RAISE)); + closeness.draw(position + Point(CLOSENESS_X, NUMBERS_Y - TEXT_RAISE)); + standing.draw(position + PET_AT, alpha); + + UIElement::draw_buttons(alpha); + } + + void UIPetChooser::update() + { + UIElement::update(); + + standing.update(); + } + + void UIPetChooser::update_screen(int16_t new_width, int16_t new_height) + { + int16_t x = std::clamp(new_width / 2 - WIDTH / 2, 0, std::max(new_width - WIDTH, 0)); + int16_t y = std::clamp(new_height / 2 - HEIGHT / 2, 0, std::max(new_height - HEIGHT, 0)); + + position = Point(x, y); + } + + void UIPetChooser::send_key(int32_t keycode, bool pressed, bool escape) + { + if (!pressed) + return; + + // FUN_009ac0b6: Escape is Cancel, and FUN_004edd21 makes Enter OK. The arrows do nothing here. + if (escape) + deactivate(); + else if (keycode == KeyAction::Id::RETURN) + choose(); + } + + UIElement::Type UIPetChooser::get_type() const + { + return TYPE; + } + + Button::State UIPetChooser::button_pressed(uint16_t buttonid) + { + size_t count = pets.size(); + + switch (buttonid) + { + case Buttons::OK: + choose(); + break; + case Buttons::CANCEL: + case Buttons::EXIT: + deactivate(); + break; + case Buttons::PREV: + show((shown + count - 1) % count); + break; + case Buttons::NEXT: + show((shown + 1) % count); + break; + default: + break; + } + + return Button::State::NORMAL; + } + + void UIPetChooser::show(size_t index) + { + shown = index; + + const Pet &pet = pets[index]; + + standing = nl::nx::item["Pet"][std::to_string(pet.itemid) + ".img"]["stand0"]; + name.change_text(pet.name); + level.change_text(std::to_string(pet.level)); + closeness.change_text(std::to_string(pet.closeness)); + } + + void UIPetChooser::choose() + { + deactivate(); + onchoose(shown); + } +} diff --git a/HeavenClient/IO/UITypes/UIPetChooser.h b/HeavenClient/IO/UITypes/UIPetChooser.h new file mode 100644 index 000000000..2a58ec12f --- /dev/null +++ b/HeavenClient/IO/UITypes/UIPetChooser.h @@ -0,0 +1,76 @@ +#pragma once + +#include "../UIElement.h" + +#include "../../Graphics/Animation.h" +#include "../../Graphics/Text.h" + +#include +#include +#include +#include + +namespace ms +{ + // Which pet a double-clicked piece of pet equipment goes on, when more than one can wear it (v83 CUtilDlgEx + // mode 6, FUN_009a9b25 on UIWindow.img/UtilDlgEx_Pet). It shows one pet at a time, standing, with its name, + // level and closeness, and Prev and Next go round them. It is modal: OK or Enter picks the pet shown, and + // Cancel, the corner button or Escape picks none. + class UIPetChooser : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::PETCHOOSER; + static constexpr bool FOCUSED = true; + static constexpr bool TOGGLED = false; + + struct Pet + { + // Its kind (500xxxx). + int32_t itemid; + std::string name; + int16_t level; + int16_t closeness; + }; + + // `onchoose` gets the index in `pets` of the pet picked. + UIPetChooser(std::vector pets, std::function onchoose); + + void draw(float alpha) const override; + + void update() override; + + void update_screen(int16_t new_width, int16_t new_height) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + // Show the pet `index` steps round from the first. + void show(size_t index); + + void choose(); + + enum Buttons : uint16_t + { + OK, + CANCEL, + EXIT, + PREV, + NEXT + }; + + std::vector pets; + std::function onchoose; + size_t shown; + Animation standing; + Texture name_tag; + Text caption; + Text name; + Text level; + Text closeness; + }; +} diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 4a879e42e..56ea5eada 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -661,24 +661,33 @@ namespace ms int8_t mode = recv.read_byte(); int32_t oid = recv.read_int(); - Optional looter; + const PhysicsObject *looter = nullptr; if (mode > 1) { int32_t cid = recv.read_int(); - // A pet's loot flies to its owner. - if (recv.length() > 0) - recv.read_byte(); // pet - if (auto character = Stage::get().get_character(cid)) - looter = character->get_phobj(); + { + // Mode 5 names the pet in its owner's list that took it, and + // it flies to that pet, or vanishes if the pet isn't out + // (FUN_004416db). + if (mode == 5) + { + if (auto pet = character->get_pet(recv.read_byte())) + looter = &pet->get_phobj(); + } + else + { + looter = &character->get_phobj(); + } + } if (Stage::get().is_player(cid)) Sound(Sound::Name::PICKUP).play(); } - Stage::get().get_drops().remove(oid, mode, looter.get()); + Stage::get().get_drops().remove(oid, mode, looter); } void HitReactorHandler::handle(InPacket& recv) const diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index a61803e6e..e8226e1b8 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -133,6 +133,7 @@ namespace ms MOVE_PET = 167, PET_CHAT = 168, PET_COMMAND = 169, + PET_LOOT = 170, MOVE_SUMMON = 175, SUMMON_ATTACK = 176, DAMAGE_SUMMON = 177, diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index ffbdb0cdb..cd62eec1e 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -669,6 +669,29 @@ namespace ms } }; + // A pet standing at `position` reaches for a drop (v83 FUN_00705c7c). + // The flags say whether its equipment lets it take others' drops, sweep + // for drops and reach far. We send no checksum for an item; the server + // doesn't read it. + // Opcode: PET_LOOT(170) + class PetLootPacket : public OutPacket + { + public: + PetLootPacket(int64_t serial, Point position, int32_t oid, bool others, bool sweep, bool longrange) + : OutPacket(OutPacket::Opcode::PET_LOOT) + { + write_long(serial); + write_byte(0); + write_time(); + write_point(position); + write_int(oid); + write_int(0); + write_byte(others); + write_byte(sweep); + write_byte(longrange); + } + }; + // Hits a reactor (v83 FUN_007356c7). The kind is 0 for a swing and 1 for // a skill, the flags bit 0 for facing left and bit 1 for standing, and // the delay the ms until the hit lands, for others to see it then. diff --git a/HeavenClient/tests/pets.cpp b/HeavenClient/tests/pets.cpp index b4246c2d4..69d701626 100644 --- a/HeavenClient/tests/pets.cpp +++ b/HeavenClient/tests/pets.cpp @@ -257,6 +257,61 @@ void naming() require(check("tigger", "Tigger").first == Naming::OK, "its name in another case"); } +void equipment() +{ + using namespace ms::pets; + + require(equip_parts(1802063) == std::array{ 14, 30, 38 }, "clothes"); + require(equip_parts(1802100) == std::array{ 21, 31, 39 }, "the collar goes with the labels"); + require(equip_parts(1822000) == std::array{ 21, 31, 39 }, "label ring"); + require(equip_parts(1832000) == std::array{ 29, 32, 40 }, "quote ring"); + require(equip_parts(1812000) == std::array{ 23, 34, 42 }, "meso magnet"); + require(equip_parts(1812002) == std::array{ 24, 24, 24 }, "the HP pouch is shared"); + require(equip_parts(1812007) == std::array{ 46, 47, 48 }, "ignore pendant"); + require(!equip_parts(1812008), "an unknown ability item"); + require(!equip_parts(1002008), "a cap"); + + require(equip_owner(14) == 0 && equip_owner(29) == 0 && equip_owner(46) == 0, "the first pet's parts"); + require(equip_owner(30) == 1 && equip_owner(37) == 1 && equip_owner(47) == 1, "the second pet's parts"); + require(equip_owner(38) == 2 && equip_owner(45) == 2 && equip_owner(48) == 2, "the third pet's parts"); + require(equip_owner(24) == -1 && equip_owner(25) == -1, "the pouches are no one's"); + require(wear_owner(30) == 1 && wear_owner(31) == -1, "clothes by pet"); + require(is_equip_part(14) && is_equip_part(48) && !is_equip_part(15) && !is_equip_part(49), "pet parts"); + + Wearer none; + Wearer tiger{ true, true, true, true, 5 }; + Wearer kitty{ true, false, false, false, 5 }; + + require(refuse_equip(14, 0, none) == CANNOT_WEAR, "clothes need a pet"); + require(refuse_equip(30, 0, kitty) == CANNOT_WEAR, "clothes made for another kind"); + require(refuse_equip(38, 0, tiger) == nullptr, "clothes for its kind"); + require(refuse_equip(14, 10, tiger) == STATS_TOO_LOW, "clothes above the pet's level"); + require(refuse_equip(21, 0, none) == NO_PET, "a label needs a pet"); + require(refuse_equip(31, 0, kitty) == CANNOT_WEAR, "a kind without a name tag"); + require(refuse_equip(39, 0, tiger) == nullptr, "a label on a kind with one"); + require(refuse_equip(32, 0, kitty) == CANNOT_WEAR, "a kind without a balloon"); + require(refuse_equip(40, 0, none) == NO_PET, "a quote ring needs a pet"); + require(refuse_equip(47, 0, none) == NO_PET, "the pendant needs a pet"); + require(refuse_equip(23, 0, kitty) == nullptr, "a magnet on any pet"); + require(refuse_equip(35, 0, none) == nullptr, "boots need none"); + require(refuse_equip(24, 0, none) == nullptr, "nor do the pouches"); + + using Choices = std::vector; + std::array alone{ kitty, none, none }; + std::array pair{ kitty, tiger, none }; + std::array three{ tiger, kitty, tiger }; + + require(equip_choices(1802063, alone) == Choices{ { 0, 14 } }, "one pet out: its part, suited or not"); + require(equip_choices(1822000, { none, none, none }) == Choices{ { 0, 21 } }, "none out: the first pet's part"); + require(equip_choices(1812002, three) == Choices{ { 0, 24 } }, "the pouches don't choose"); + require(equip_choices(1802063, pair) == Choices{ { 1, 30 } }, "clothes for the one pet of their kind"); + require(equip_choices(1802063, three) == Choices{ { 0, 14 }, { 2, 38 } }, "clothes for the pets of their kind"); + require(equip_choices(1812000, three) == Choices{ { 0, 23 }, { 1, 34 }, { 2, 42 } }, "a magnet for any"); + require(equip_choices(1832000, pair) == Choices{ { 0, 29 }, { 1, 32 } }, "the quote ring for any"); + require(equip_choices(1822001, three).empty(), "other rings don't choose"); + require(equip_choices(1002008, three).empty(), "not pet equipment"); +} + int main() { try @@ -270,6 +325,7 @@ int main() actions(); food(); naming(); + equipment(); std::cout << "Pets passed\n"; return 0; diff --git a/HeavenClient/tests/pickup.cpp b/HeavenClient/tests/pickup.cpp index b321cbd6d..ca9e95be8 100644 --- a/HeavenClient/tests/pickup.cpp +++ b/HeavenClient/tests/pickup.cpp @@ -33,6 +33,13 @@ int main() require(!owned_by_other(2, 7, 8, 0, 0), "a free-for-all drop is anyone's"); require(!owned_by_other(3, 7, 8, 0, 0), "an explosive drop is anyone's"); + require(pet_owned_by_other(0, 7, 8, 0, 60000, false), "a pet never takes another's drop"); + require(!pet_owned_by_other(0, 8, 8, 0, 0, false), "a pet takes its owner's drop at once"); + require(!pet_owned_by_other(1, 4, 8, 4, 0, false), "and its owner's party's"); + require(!pet_owned_by_other(2, 7, 8, 0, 0, false), "and a free-for-all drop"); + require(pet_owned_by_other(0, 7, 8, 0, OWNERSHIP, true), "Magic Scales wait out the 15 s"); + require(!pet_owned_by_other(0, 7, 8, 0, OWNERSHIP + 1, true), "then take another's drop"); + std::cout << "Pickup passed\n"; return 0; } diff --git a/game-data/src/items.rs b/game-data/src/items.rs index e3aea5edd..e4c62d2b9 100644 --- a/game-data/src/items.rs +++ b/game-data/src/items.rs @@ -39,6 +39,12 @@ pub struct ItemInfo { pub pet_commands: Vec, /// The fullness a pet loses each time it grows hungry (`info/hungry`). pub pet_hunger: i32, + /// Whether a pet has a name tag style (`info/nameTag`), which lets it + /// wear a label ring. + pub pet_name_tag: bool, + /// Whether a pet has a chat balloon style (`info/chatBalloon`), which + /// lets it wear a quote ring. + pub pet_chat_balloon: bool, } /// A skill book (228xxxx) or mastery book (229xxxx). @@ -129,6 +135,9 @@ pub struct EquipInfo { /// `info/recovery`: how much faster the wearer recovers on a map whose /// own recovery is above 1 (the sauna robes, 1.5). pub recovery: Option, + /// The pets that pet wear (180xxxx) suits: the image's children named + /// for pet item ids. + pub pets: Vec, } /// Top-level `Item.nx` directory for a non-equipment item id. @@ -234,6 +243,8 @@ impl ItemData { info.recovery_mp = nx.int_child(meta, "recoveryMP")?.unwrap_or(0); if pet { info.pet_hunger = nx.int_child(meta, "hungry")?.unwrap_or(1); + info.pet_name_tag = nx.int_child(meta, "nameTag")?.unwrap_or(0) != 0; + info.pet_chat_balloon = nx.int_child(meta, "chatBalloon")?.unwrap_or(0) != 0; } if let Some(skills) = nx.child_by_name(meta, "skill")? { let mut book = BookInfo { @@ -321,6 +332,14 @@ impl ItemData { let Some(meta) = nx.child_by_name(node, "info")? else { return Ok(None); }; + let mut pets = Vec::new(); + if item_id / 10_000 == 180 { + for child in nx.child_indices(node)? { + if let Ok(pet) = nx.node_name(child)?.parse::() { + pets.push(pet); + } + } + } let stat = |key: &str| -> Result { Ok(nx .int_child(meta, key)? @@ -351,6 +370,7 @@ impl ItemData { req_luk: stat("reqLUK")?, cash: nx.int_child(meta, "cash")? == Some(1), recovery: nx.real_child(meta, "recovery")?, + pets, }; Ok(Some(ItemInfo { item_id, @@ -368,6 +388,8 @@ impl ItemData { equip: Some(equip), pet_commands: Vec::new(), pet_hunger: 0, + pet_name_tag: false, + pet_chat_balloon: false, })) } } @@ -511,6 +533,9 @@ mod tests { let tiger = data.info(5000066).unwrap().unwrap(); assert_eq!(tiger.slot_max, 1); assert_eq!(tiger.pet_hunger, 3); + assert!(tiger.pet_name_tag && tiger.pet_chat_balloon); + let kitty = data.info(5000000).unwrap().unwrap(); + assert!(!kitty.pet_name_tag && !kitty.pet_chat_balloon); assert_eq!(data.info(5000000).unwrap().unwrap().pet_hunger, 2); let food = data.info(2120000).unwrap().unwrap().spec.unwrap(); assert_eq!(food.inc, 30); @@ -617,4 +642,17 @@ mod tests { let robe = data.info(1050018).unwrap().unwrap().equip.unwrap(); assert_eq!(robe.recovery, Some(1.5)); } + + #[test] + fn reads_which_pets_pet_wear_suits() { + let Some(data) = data() else { + return; + }; + let wings = data.info(1802063).unwrap().unwrap().equip.unwrap(); + assert_eq!((wings.pets, wings.cash), (vec![5000066], true)); + let hat = data.info(1802000).unwrap().unwrap().equip.unwrap(); + assert!(hat.pets.contains(&5000000) && !hat.pets.contains(&5000066)); + let magnet = data.info(1812000).unwrap().unwrap().equip.unwrap(); + assert!(magnet.pets.is_empty()); + } } diff --git a/game/src/character.rs b/game/src/character.rs index bab432599..53e31824a 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -242,12 +242,16 @@ impl Player { /// The summoned pets' items, in list order. pub fn summoned_pets(&self) -> impl Iterator + '_ { - self.pets.iter().filter_map(|&serial| { - self.inventory - .items(InventoryType::Cash) - .map(|(_, item)| item) - .find(|item| item.cash_serial == Some(serial) && item.pet.is_some()) - }) + (0..self.pets.len()).filter_map(|index| self.summoned_pet(index)) + } + + /// The item of the pet at `index` in the list, if one is out there. + pub fn summoned_pet(&self, index: usize) -> Option<&Item> { + let serial = *self.pets.get(index)?; + self.inventory + .items(InventoryType::Cash) + .map(|(_, item)| item) + .find(|item| item.cash_serial == Some(serial) && item.pet.is_some()) } /// The summoned pets' cash serials, 0 past the last. diff --git a/game/src/inventory.rs b/game/src/inventory.rs index 3a244fe4f..b37a4290d 100644 --- a/game/src/inventory.rs +++ b/game/src/inventory.rs @@ -109,6 +109,42 @@ pub mod equip_slot { } } + /// The slots a pet equip may be worn in, one for each summoned pet in + /// list order (v83 FUN_00460358). The HP and MP pouches serve every pet + /// from one slot. + pub fn pet_slots(item_id: i32) -> Option<[i16; 3]> { + let parts = match item_id { + // The Pet Collar goes where a label ring does. + 1_802_100 => [21, 31, 39], + 1_812_000 => [23, 34, 42], + 1_812_001 => [22, 33, 41], + 1_812_002 => [24; 3], + 1_812_003 => [25; 3], + 1_812_004 => [26, 35, 43], + 1_812_005 => [27, 36, 44], + 1_812_006 => [28, 37, 45], + 1_812_007 => [46, 47, 48], + _ => match item_id / 10_000 { + 180 => [14, 30, 38], + 182 => [21, 31, 39], + 183 => [29, 32, 40], + _ => return None, + }, + }; + Some(parts.map(|part| -part)) + } + + /// The list index of the pet a pet equip slot serves, or `None` for the + /// pouches every pet shares (v83 FUN_00705e2c). + pub fn pet_index(slot: i16) -> Option { + match -slot { + 14 | 21..=23 | 26..=29 | 46 => Some(0), + 30..=37 | 47 => Some(1), + 38..=45 | 48 => Some(2), + _ => None, + } + } + /// Whether `item_id` may be worn at `position`. As in v83, an item with /// a cash serial is worn at its slot less 100, and any other at its slot. pub fn fits(item_id: i32, cash: bool, position: i16) -> bool { @@ -117,6 +153,9 @@ pub mod equip_slot { (false, -99..=-1) => position, _ => return false, }; + if let Some(slots) = pet_slots(item_id) { + return slots.contains(&position); + } match for_item(item_id) { Some(slot) if RINGS.contains(&slot) => RINGS.contains(&position), Some(slot) => slot == position, @@ -1008,6 +1047,29 @@ mod tests { assert_eq!(inventory.equipped_cash().count(), 1); } + #[test] + fn pet_equips_go_in_their_pets_slots() { + // Wings for the second pet, a meso magnet for the third, and an HP + // pouch, which every pet shares. + for (item_id, position, index) in [ + (1802063, -130, Some(1)), + (1812000, -142, Some(2)), + (1812002, -124, None), + ] { + assert!(equip_slot::fits(item_id, true, position), "{item_id}"); + assert_eq!(equip_slot::pet_index(position + 100), index); + } + assert!(!equip_slot::fits(1802063, true, -129), "a quote ring's"); + assert!(!equip_slot::fits(1812002, true, -133)); + assert!(!equip_slot::fits(1812008, true, -128), "no such ability"); + assert!(!equip_slot::fits(1832000, true, equip_slot::CAP - 100)); + assert!(equip_slot::fits(1832000, true, -132)); + assert!( + equip_slot::fits(1802100, true, -139), + "the collar's a label" + ); + } + #[test] fn overalls_and_pants_take_each_other_off() { let mut inventory = Inventory::default(); diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index b28fd11bb..7af3e1ae5 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -110,6 +110,56 @@ pub async fn set_job(character_id: i32, job: i16, level: i16, mp: i16) -> Result .await } +/// Put the pet `item_id` named `name`, with cash serial `serial`, in slot +/// `slot` of the logged-out character `character_id`'s Cash inventory. +pub async fn grant_pet( + character_id: i32, + slot: i16, + item_id: i32, + serial: i64, + name: &str, +) -> Result<(), HarnessError> { + let name = name.to_string(); + change_one_row("grant pet", move |conn| { + diesel::sql_query( + "WITH pet AS (INSERT INTO pets (serial, name) VALUES ($4, $5)) \ + INSERT INTO inventory_items (character_id, inventory_type, slot, item_id, quantity, owner, flags, cash_serial) \ + VALUES ($1, 5, $2, $3, 1, '', 0, $4)", + ) + .bind::(character_id) + .bind::(slot) + .bind::(item_id) + .bind::(serial) + .bind::(name) + .execute(conn) + }) + .await +} + +/// Dress the logged-out character `character_id` in the cash equip +/// `item_id`, with cash serial `serial`, at `position` (negative), such as +/// a pet's equipment. +pub async fn wear_cash_equip( + character_id: i32, + position: i16, + item_id: i32, + serial: i64, +) -> Result<(), HarnessError> { + change_one_row("wear cash equip", move |conn| { + diesel::sql_query( + "WITH item AS (INSERT INTO inventory_items (character_id, inventory_type, slot, item_id, quantity, owner, flags, cash_serial) \ + VALUES ($1, -1, $2, $3, 1, '', 0, $4) RETURNING id) \ + INSERT INTO equipment_stats (item_row_id) SELECT id FROM item", + ) + .bind::(character_id) + .bind::(position) + .bind::(item_id) + .bind::(serial) + .execute(conn) + }) + .await +} + /// Set the fullness of the pet `serial`, whose owner is logged out. pub async fn set_pet_fullness(serial: i64, fullness: i16) -> Result<(), HarnessError> { change_one_row("set pet fullness", move |conn| { diff --git a/integration-harness/tests/pet_loot.rs b/integration-harness/tests/pet_loot.rs new file mode 100644 index 000000000..55b00ceb2 --- /dev/null +++ b/integration-harness/tests/pet_loot.rs @@ -0,0 +1,116 @@ +//! Pet loot: a pet wearing a Meso Magnet picks up its owner's mesos, which +//! fly to the pet, and without an Item Pouch it leaves items where they lie. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_pet, wear_cash_equip}; +use integration_harness::hunting::attack; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read, read_for, send, stat, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::write::PktWrite; +use packet::Packet; + +const FOREST: i32 = 50_000; +const SNAIL: i32 = 100100; +const BABY_TIGER: i32 = 5_000_066; +const MESO_MAGNET: i32 = 1_812_000; +/// Where the first pet out wears its Meso Magnet. +const FIRST_PET_MAGNET: i16 = -123; + +/// SPAWN_PET: the pet in Cash slot `slot` comes out. +fn spawn_pet(slot: i16) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::SpawnPet as i16).unwrap(); + p.write_int(0).unwrap(); // time + p.write_short(slot).unwrap(); + p.write_byte(0).unwrap(); // not leading + p +} + +/// PET_LOOT: the pet `serial`, standing at (x, y), reaches for a drop. +fn pet_loot(serial: i64, object_id: i32, x: i16, y: i16) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::PetLoot as i16).unwrap(); + p.write_long(serial).unwrap(); + p.write_byte(0).unwrap(); // field key + p.write_int(0).unwrap(); // time + p.write_short(x).unwrap(); + p.write_short(y).unwrap(); + p.write_int(object_id).unwrap(); + p.write_int(0).unwrap(); // checksum + p.write_bytes(&[0, 0, 0]).unwrap(); // others' drops, sweep, long range + p +} + +/// Read until the drop `object_id` leaves the map, and return the body. +async fn removal(c: &mut MapleTestConnection, t: &mut Trace, object_id: i32) -> Vec { + loop { + let p = read(c, t).await; + if p.opcode() == SendOpcode::RemoveItemFromMap as i16 + && p.bytes[3..7] == object_id.to_le_bytes() + { + return p.bytes[2..].to_vec(); + } + } +} + +#[tokio::test] +async fn a_pet_with_a_meso_magnet_takes_mesos_but_not_items() { + let config = load_harness_config_or_fail().await; + let session = login_to_world_session(&config).await.unwrap(); + let owner_id = session.character_id; + drop(session); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let serial = 9_159_000_000 + 2 * i64::from(owner_id); + grant_pet(owner_id, 1, BABY_TIGER, serial, "Baby Tiger") + .await + .expect("grant a pet"); + wear_cash_equip(owner_id, FIRST_PET_MAGNET, MESO_MAGNET, serial + 1) + .await + .expect("put the magnet on the pet"); + let mut owner = relogin(&config).await; + let (o, mut t) = (&mut owner.connection, Trace::default()); + assert_eq!(warp(o, FOREST, "", &mut t).await, FOREST); + until(o, &mut t, |t| t.field.mobs.len() >= 17).await; + send(o, spawn_pet(1)).await; + until(o, &mut t, |t| !t.pets.is_empty()).await; + read_for(o, &mut t, 300).await; + // Earlier tests may have left drops on this map. + let old_drops = t.field.drops.len(); + + // Kill snails until mesos drop; every kill drops a Monster Book card + // as well (tests/monster_book.rs). + for oid in t.field.alive(SNAIL) { + send(o, attack(oid, 8)).await; + until(o, &mut t, |t| t.field.killed.contains(&oid)).await; + read_for(o, &mut t, 200).await; + if t.field.drops[old_drops..].iter().any(|d| d.meso) { + break; + } + } + let ours = &t.field.drops[old_drops..]; + let mesos = *ours + .iter() + .find(|d| d.meso) + .expect("seventeen snails dropped no mesos"); + let card = *ours + .iter() + .find(|d| !d.meso && d.value / 10_000 == 238) + .expect("a card dropped"); + + // Without an Item Pouch the pet leaves the card. + send(o, pet_loot(serial, card.object_id, card.x, card.y)).await; + read_for(o, &mut t, 400).await; + assert!(t.field.removals(card.object_id).is_empty()); + + // The mesos fly to the first pet, and are its owner's. + send(o, pet_loot(serial, mesos.object_id, mesos.x, mesos.y)).await; + let mut taken = vec![5]; + taken.extend(mesos.object_id.to_le_bytes()); + taken.extend(owner_id.to_le_bytes()); + taken.push(0); + assert_eq!(removal(o, &mut t, mesos.object_id).await, taken); + until(o, &mut t, |t| t.stat(stat::MESO).is_some()).await; + assert_eq!(t.stat(stat::MESO), Some(i64::from(mesos.value))); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index d8b8f2f41..358e8f1f1 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -515,6 +515,17 @@ pub enum FieldRequest { skill_id: Option, why: crate::packet::build::world::summon::Gone, }, + /// The player's pet at `index` in their pet list, standing at (x, y), + /// would pick up the drop `object_id`. It may take mesos with `mesos` + /// and items with `items`. Answered with `ServerMessage::PetPickedUp`. + PetPickup { + index: u8, + object_id: i32, + x: i16, + y: i16, + mesos: bool, + items: bool, + }, /// The player's client moved their pet at `index` in their pet list. PetMove { index: u8, @@ -672,6 +683,7 @@ fn world_routes() -> &'static HashMap { (Op::PetChat, InGame(&world::PetChatHandler)), (Op::PetCommand, InGame(&world::PetCommandHandler)), (Op::PetFood, InGame(&world::PetFoodHandler)), + (Op::PetLoot, InGame(&world::PetLootHandler)), (Op::UseCashItem, InGame(&world::UseCashItemHandler)), (Op::CloseChalkboard, InGame(&world::CloseChalkboardHandler)), ( diff --git a/net/src/packet/build/world/drop.rs b/net/src/packet/build/world/drop.rs index 5450d9dc7..7be9e5948 100644 --- a/net/src/packet/build/world/drop.rs +++ b/net/src/packet/build/world/drop.rs @@ -60,6 +60,18 @@ pub fn build_drop(drop: &DropView, appearance: DropAppearance) -> Result Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::RemoveItemFromMap as i16)?; + p.write_byte(5)?; + p.write_int(object_id)?; + p.write_int(owner)?; + p.write_byte(pet)?; + Ok(p) +} + /// REMOVE_ITEM_FROM_MAP: 0 expired, 2 picked up by `looter`. pub fn build_remove_drop(object_id: i32, looter: Option) -> Result { let mut p = Packet::new_empty(); diff --git a/net/src/packet/handle/world/item_move.rs b/net/src/packet/handle/world/item_move.rs index 97495daf4..9aa7c00e2 100644 --- a/net/src/packet/handle/world/item_move.rs +++ b/net/src/packet/handle/world/item_move.rs @@ -4,7 +4,9 @@ use crate::handler::{ use crate::items; use crate::loot::Loot; use crate::packet::build::world::{inventory, stat}; +use game::inventory::equip_slot; use game::{InventoryType, Item, Player}; +use game_data::items::EquipInfo; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use tracing::debug; @@ -144,7 +146,12 @@ fn move_item( let template = items::info(item_id) .and_then(|info| info.equip) .ok_or("unknown equipment")?; - if !items::can_wear(&template, player) { + let allowed = match equip_slot::pet_slots(item_id) { + // Cash items are worn 100 below their slot. + Some(_) => pet_can_wear(player, &template, to % 100), + None => items::can_wear(&template, player), + }; + if !allowed { return Err("requirements not met".into()); } let changes = player @@ -165,6 +172,29 @@ fn move_item( Ok((changes, false)) } +/// Whether a pet equip may go in pet equip `slot`, as the client checks +/// (v83 FUN_004f1c2d). Wear, label and quote rings, item pouches, meso +/// magnets and ignore pendants need the pet they are for out. Wear must +/// suit that pet's kind and level, a label ring its name tag style and a +/// quote ring its chat balloon style. The rest need no pet. +fn pet_can_wear(player: &Player, equip: &EquipInfo, slot: i16) -> bool { + let pet = equip_slot::pet_index(slot).and_then(|index| player.summoned_pet(index)); + let kind = |pet: &Item| items::info(pet.item_id).unwrap_or_default(); + match -slot { + 14 | 30 | 38 => pet.is_some_and(|pet| { + equip.pets.contains(&pet.item_id) + && pet + .pet + .as_ref() + .is_some_and(|data| i16::from(data.level) >= equip.req_level) + }), + 21 | 31 | 39 => pet.is_some_and(|pet| kind(pet).pet_name_tag), + 29 | 32 | 40 => pet.is_some_and(|pet| kind(pet).pet_chat_balloon), + 22 | 23 | 46 | 33 | 34 | 47 | 41 | 42 | 48 => pet.is_some(), + _ => true, + } +} + #[cfg(test)] mod tests { use super::*; @@ -332,6 +362,58 @@ mod tests { assert!(player.inventory.get(InventoryType::Equip, 5).is_some()); } + /// A player with a Brown Kitty out first and a Baby Tiger second, and + /// `equips` in the Equip bag from slot 1, bought in the Cash Shop. + fn pet_owner(equips: &[i32]) -> Player { + let mut player = crate::codec::stats::sample_player(); + for (slot, (serial, item_id)) in (1..).zip([(76, 5000000), (77, 5000066)]) { + let mut pet = Item::stack(item_id, 1); + pet.cash_serial = Some(serial); + pet.pet = Some(game::PetData::new("Pet")); + player.inventory.insert(InventoryType::Cash, slot, pet); + player.pets.push(serial); + } + for (slot, &item_id) in (1..).zip(equips) { + let mut equip = Item::equipment(item_id, EquipStats::default()); + equip.cash_serial = Some(100 + i64::from(slot)); + player.inventory.insert(InventoryType::Equip, slot, equip); + } + player + } + + fn worn(actions: &[WorldAction]) -> bool { + matches!(actions, [WorldAction::Reply(p), WorldAction::LookChanged(_)] + if p.opcode() == SendOpcode::InventoryOperation as i16) + } + + #[test] + fn pet_equips_need_the_pet_they_are_for() { + if items::data().is_err() { + return; + } + // Tiger wings, a label ring, a meso magnet, an HP pouch and a sweeper. + let mut player = pet_owner(&[1802063, 1822000, 1812000, 1812002, 1812004]); + // The kitty can't wear the tiger's wings, nor show a label. + assert!(!worn(&handle(&mut player, request(1, 1, -114)))); + assert!(!worn(&handle(&mut player, request(1, 2, -121)))); + assert!(worn(&handle(&mut player, request(1, 1, -130)))); + assert!(worn(&handle(&mut player, request(1, 2, -131)))); + // There is no third pet for the magnet, but the pouch and the + // sweeper need none. + assert!(!worn(&handle(&mut player, request(1, 3, -142)))); + assert!(worn(&handle(&mut player, request(1, 4, -124)))); + assert!(worn(&handle(&mut player, request(1, 5, -143)))); + let worn_ids: Vec = [-130, -131, -124, -143] + .map(|position| { + let item = player.inventory.get(InventoryType::Equipped, position); + item.map_or(0, |item| item.item_id) + }) + .into(); + assert_eq!(worn_ids, [1802063, 1822000, 1812002, 1812004]); + let look = player.look(); + assert_eq!(look.visible.get(&30), Some(&1802063)); + } + #[test] fn refused_moves_only_unlock_the_client() { let mut player = crate::codec::stats::sample_player(); diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index fc12aead2..8ef0084f8 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -74,7 +74,7 @@ pub use self::party::{DenyPartyRequestHandler, MultiChatHandler, PartyOperationH pub use self::party_search_update::PartySearchUpdateHandler; pub use self::pet::{ pets_grow_hungry, Hungry, MovePetHandler, PetChatHandler, PetCommandHandler, PetFoodHandler, - SpawnPetHandler, + PetLootHandler, SpawnPetHandler, }; pub use self::pickup::ItemPickupHandler; pub use self::player_interaction::PlayerInteractionHandler; diff --git a/net/src/packet/handle/world/pet.rs b/net/src/packet/handle/world/pet.rs index a60cb3bcb..a00736890 100644 --- a/net/src/packet/handle/world/pet.rs +++ b/net/src/packet/handle/world/pet.rs @@ -10,6 +10,7 @@ use crate::packet::build::world::pet::{ build_cash_pet_food, build_pet_chat, build_pet_command, build_pet_food, build_pet_level_up, }; use crate::packet::build::world::stat::{build_stat_update, StatUpdate}; +use game::inventory::equip_slot; use game::pet::{PetChange, PetCommand}; use game::{InventoryChange, InventoryType, Item, Player}; use game_data::items::ConsumeSpec; @@ -77,6 +78,54 @@ impl WorldHandler for MovePetHandler { } } +/// PET_LOOT (0xAA, FUN_00705c7c): the pet's cash serial, the field key, +/// the tick, where the pet stands, the drop, its CRC and the pet's sweep, +/// range and pick-up-others flags. A pet takes mesos with a meso magnet +/// and items with an item pouch worn for it (FUN_00705e2c). The client +/// doesn't wait for an answer. +pub struct PetLootHandler; + +impl WorldHandler for PetLootHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let serial = r.read_long()?; + r.read_byte()?; + r.read_int()?; + let x = r.read_short()?; + let y = r.read_short()?; + let object_id = r.read_int()?; + let player = &*ctx.player; + let index = player.pets.iter().position(|&pet| pet == serial); + let Some(index) = index.filter(|_| player.stats.hp > 0) else { + return Ok(HandlerResult::empty()); + }; + let worn = |item_id| { + equip_slot::pet_slots(item_id).is_some_and(|slots| { + let position = slots[index] - 100; + player + .inventory + .get(InventoryType::Equipped, position) + .is_some() + }) + }; + let (mesos, items) = (worn(1_812_000), worn(1_812_001)); + if !mesos && !items { + return Ok(HandlerResult::empty()); + } + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::PetPickup { + index: index as u8, + object_id, + x, + y, + mesos, + items, + })), + ) + } +} + /// PET_COMMAND (0xA9, FUN_00704d5d): the pet's cash serial, whether the /// words began with its name, and the command (`interact/`) they /// matched. The server rolls whether the pet obeys; obeying earns diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 1a28a2cb9..1bace5802 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -83,6 +83,7 @@ pub enum RecvOpcode { MovePet = 0xA7, PetChat = 0xA8, PetCommand = 0xA9, + PetLoot = 0xAA, MoveSummon = 0xAF, SummonAttack = 0xB0, DamageSummon = 0xB1, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 2480853db..f84e80fbe 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -1050,6 +1050,7 @@ impl ClientActor { y, } => self.drop_reactor_loot(reactor_id, object_id, x, y).await?, ServerMessage::PickedUp { drop } => self.picked_up(drop).await?, + ServerMessage::PetPickedUp { drop, pet } => self.pet_picked_up(drop, pet).await?, ServerMessage::Locate { reply_to } => self.locate(&reply_to), ServerMessage::RockLocate { reply_to, rock } => { let found = self diff --git a/runtime/src/actor/client/loot.rs b/runtime/src/actor/client/loot.rs index 7513c1e73..4e52000b2 100644 --- a/runtime/src/actor/client/loot.rs +++ b/runtime/src/actor/client/loot.rs @@ -58,9 +58,34 @@ impl ClientActor { /// The field handed over a drop: put it in the inventory, or give it back /// if it doesn't fit. pub(super) async fn picked_up(&mut self, drop: Drop) -> Result<(), RuntimeError> { + if !self.take_loot(&drop).await? { + return self.send_to_field(FieldMessage::ReturnDrop { drop }).await; + } + Ok(()) + } + + /// The field handed this player's pet at `pet` a drop it still shows: + /// put it in the inventory, and have the field show the pet taking it or + /// leave it lying. + pub(super) async fn pet_picked_up(&mut self, drop: Drop, pet: u8) -> Result<(), RuntimeError> { + let taken = self.take_loot(&drop).await?; + let looter = self.in_game()?.player.id(); + self.send_to_field(FieldMessage::PetLooted { + looter, + pet, + drop, + taken, + }) + .await + } + + /// Put a drop in the inventory. Returns false, having told the player, + /// when it doesn't fit. + async fn take_loot(&mut self, drop: &Drop) -> Result { if let Loot::Item { item_id, .. } = drop.loot { if monster_book::is_card(item_id) { - return self.collect_card(item_id).await; + self.collect_card(item_id).await?; + return Ok(true); } } let mut packets = Vec::new(); @@ -72,7 +97,8 @@ impl ClientActor { let meso = player.meso; packets.push(stat::build_stat_update(true, &[StatUpdate::Meso(meso)])?); packets.push(drop::build_meso_gain(*amount)?); - return self.send_all(&mut packets).await; + self.send_all(&mut packets).await?; + return Ok(true); } Loot::Item { item_id, quantity, .. @@ -100,10 +126,11 @@ impl ClientActor { packets.push(inventory::build_inventory_full()?); packets.push(stat::build_stat_update(true, &[])?); self.send_all(&mut packets).await?; - return self.send_to_field(FieldMessage::ReturnDrop { drop }).await; + return Ok(false); } } - self.send_all(&mut packets).await + self.send_all(&mut packets).await?; + Ok(true) } /// A picked-up card goes straight into the Monster Book; once its page diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 70ab7a6da..369bb88cd 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -11,7 +11,9 @@ use game::{FieldAvatar, MobStat}; use net::conti::{self, Happening}; use net::packet::build::world::conti::build_conti_move; use net::packet::build::world::door::{build_remove_door, build_spawn_door}; -use net::packet::build::world::drop::{build_drop, build_remove_drop, DropAppearance}; +use net::packet::build::world::drop::{ + build_drop, build_pet_loot_drop, build_remove_drop, DropAppearance, +}; use net::packet::build::world::field::{ build_player_enter_field, build_player_leave_field, build_show_chair, parse_movement, parse_movement_state, @@ -426,6 +428,24 @@ impl FieldActor { start, movement, } => self.handle_pet_move(from, index, start, movement), + FieldRequest::PetPickup { + index, + object_id, + x, + y, + mesos, + items, + } => { + let now = Instant::now(); + let taken = self + .drops + .take_for_pet(from, object_id, (x, y), now, mesos, items); + if let (Ok(drop), Some(occupant)) = (taken, self.occupants.get(&from)) { + occupant + .handle + .send(ServerMessage::PetPickedUp { drop, pet: index }); + } + } FieldRequest::SummonAttack { attack, limits } => { self.handle_summon_attack(from, attack, &limits) } @@ -443,6 +463,20 @@ impl FieldActor { y, dropper, } => self.spawn_loot(owner, loot, x, y, dropper), + FieldMessage::PetLooted { + looter, + pet, + drop, + taken, + } => { + // The pet's drop was never taken off the field, so it only + // goes back in the set when it didn't fit. + if !taken { + self.drops.restore(drop); + } else if let Ok(packet) = build_pet_loot_drop(drop.object_id, looter, pet) { + self.send_drop_packet(&drop, packet); + } + } FieldMessage::ReturnDrop { drop } => { let now = Instant::now(); if let Ok(packet) = build_drop(&drop.view(now), DropAppearance::Lying) { diff --git a/runtime/src/actor/field_drops.rs b/runtime/src/actor/field_drops.rs index b5cc680d0..956920050 100644 --- a/runtime/src/actor/field_drops.rs +++ b/runtime/src/actor/field_drops.rs @@ -66,6 +66,8 @@ pub enum PickupError { Gone, TooFar, NotYours, + /// A pet without the item for it, or a drop a player threw away. + NotForPets, } pub struct DropSet { @@ -174,6 +176,29 @@ impl DropSet { self.drops.remove(&object_id).ok_or(PickupError::Gone) } + /// Take a drop for the pet of `client` standing at (x, y), which takes + /// mesos with `mesos` and items with `items`. As in v83 (FUN_0050483a), + /// pets leave what players threw away. + pub fn take_for_pet( + &mut self, + client: ClientId, + object_id: i32, + (x, y): (i16, i16), + now: Instant, + mesos: bool, + items: bool, + ) -> Result { + let drop = self.drops.get(&object_id).ok_or(PickupError::Gone)?; + let wanted = match drop.loot { + Loot::Mesos(_) => mesos, + _ => items, + }; + if drop.player_drop || !wanted { + return Err(PickupError::NotForPets); + } + self.take(client, object_id, x, y, now) + } + /// Put back a drop that couldn't be picked up (a full inventory). pub fn restore(&mut self, drop: Drop) { self.drops.insert(drop.object_id, drop); @@ -242,6 +267,55 @@ mod tests { assert!(drops.take(2, ids[1], 100, 0, now + OWNERSHIP).is_ok()); } + #[test] + fn pets_take_what_their_items_allow_but_not_what_players_threw() { + let now = Instant::now(); + let mut drops = DropSet::default(); + let ids: Vec = drops + .spawn( + vec![ + Loot::Mesos(5), + Loot::Item { + item_id: 4000019, + quantity: 1, + quest: None, + }, + ], + 1, + 100, + 0, + 9, + now, + ) + .iter() + .map(|d| d.object_id) + .collect(); + let thrown = drops.throw(Loot::Mesos(7), 1, 100, 0, now).object_id; + let at = (100, 0); + // A meso magnet alone leaves items, and no pet takes what was thrown. + assert_eq!( + drops + .take_for_pet(1, ids[1], at, now, true, false) + .unwrap_err(), + PickupError::NotForPets + ); + assert_eq!( + drops + .take_for_pet(1, thrown, at, now, true, true) + .unwrap_err(), + PickupError::NotForPets + ); + // Someone else's pet waits for the owner's time to pass. + assert_eq!( + drops + .take_for_pet(2, ids[0], at, now, true, true) + .unwrap_err(), + PickupError::NotYours + ); + let drop = drops.take_for_pet(1, ids[0], at, now, true, false).unwrap(); + assert_eq!(drop.loot, Loot::Mesos(5)); + } + #[test] fn thrown_drops_are_anyones_at_once() { let now = Instant::now(); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 33d23362e..fb66585fc 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -96,6 +96,12 @@ pub enum ServerMessage { PickedUp { drop: crate::actor::field_drops::Drop, }, + /// The field gave this player's pet at `pet` a drop it still shows; + /// answer with `FieldMessage::PetLooted`. + PetPickedUp { + drop: crate::actor::field_drops::Drop, + pet: u8, + }, /// Whether a quest NPC is on the player's map and within reach. QuestNpcChecked { request_id: u64, @@ -292,6 +298,14 @@ pub enum FieldMessage { ReturnDrop { drop: crate::actor::field_drops::Drop, }, + /// The pet at `pet` of `looter` took the drop it was given, or with + /// `taken` false left it where it lies. + PetLooted { + looter: ClientId, + pet: u8, + drop: crate::actor::field_drops::Drop, + taken: bool, + }, /// Check that an NPC with this template id is near the player before a /// quest action; answered with `ServerMessage::QuestNpcChecked`. QuestNpc {