diff --git a/AGENTS.md b/AGENTS.md index b44118037..a52cb0fd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This repository is a Rust workspace for a MapleStory server plus a nested C++ cl - `agent_docs/combat.md`: mob spawning and control, melee attacks, EXP and level-ups, spending AP and SP, drops and pickup, touch damage and death - `agent_docs/parties.md`: party state and flows, EXP sharing, party chat, and the client's party window and HP displays - `agent_docs/buddies.md`: buddy list storage, requests, channel visibility, buddy chat, and the client's Friend tab -- `agent_docs/guilds.md`: guild storage, founding at Heracle, guild chat, name tags, and the client's Guild tab +- `agent_docs/guilds.md`: guild storage, founding at Heracle, invitations, leaving and expelling, guild chat, name tags, and the client's Guild tab - `agent_docs/inventory.md`: inventory operations, item data, item moves, NPC shops, storage, and their packets - `agent_docs/server-content.md`: the ignored content pack (script text, drops), placeholders, and the importer - `agent_docs/client-testing.md`: fast client logic tests and private-display GUI verification diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 645631dd1..7c4b38a78 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -160,6 +160,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(mini_room_tests tests/mini_room.cpp Net/InPacket.cpp) target_include_directories(mini_room_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME mini_room COMMAND mini_room_tests) + add_executable(personal_shop_tests tests/personal_shop.cpp Net/InPacket.cpp) + target_include_directories(personal_shop_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME personal_shop COMMAND personal_shop_tests) add_executable(movement_path_tests tests/movement_path.cpp Net/InPacket.cpp Net/Handlers/Helpers/MovementParser.cpp) target_include_directories(movement_path_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME movement_path COMMAND movement_path_tests) @@ -309,6 +312,9 @@ add_executable(HeavenClient Character/MonsterBook.h Character/Party.cpp Character/Party.h + Character/MiniRoomBalloon.cpp + Character/MiniRoomBalloon.h + Character/MiniRoomBox.h Character/OtherChar.cpp Character/OtherChar.h Character/PassiveBuffs.cpp @@ -586,6 +592,8 @@ add_executable(HeavenClient IO/UITypes/UIInviteCard.h IO/UITypes/UITradingRoom.cpp IO/UITypes/UITradingRoom.h + IO/UITypes/UIPersonalShop.cpp + IO/UITypes/UIPersonalShop.h IO/UITypes/UIMonsterBook.cpp IO/UITypes/UIMonsterBook.h IO/UITypes/UISkillBook.cpp @@ -621,6 +629,9 @@ add_executable(HeavenClient IO/BuddyCommands.h IO/PartyCommands.cpp IO/PartyCommands.h + IO/GuildCommands.cpp + IO/GuildCommands.h + IO/ChatCommand.h IO/UI.cpp IO/UI.h IO/UIDragElement.h diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index ae50eaeac..3a2685d63 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -33,6 +33,11 @@ namespace ms // (FUN_005f0334). Not yet checked against period footage. constexpr int16_t GUILD_TAG_GAP = 1; constexpr Point EMBLEM_OFFSET = Point(-GuildMark::SIZE - 3, 6); + + // v83 hangs a room's bubble over the avatar's drawn height, hat and + // all (FUN_0048e85a); this client doesn't measure it, so it takes a + // bare head's. + constexpr int16_t AVATAR_HEIGHT = 70; } Char::Char(int32_t o, const CharLook &lk, const std::string &name) : MapObject(o), look(lk), look_preview(lk), namelabel( @@ -114,6 +119,8 @@ namespace ms chatballoon.draw(absp - Point(0, 85)); hintballoon.draw(absp - Point(0, 85)); + // v83 puts a room's balloon in front of the chat balloon. + miniroom.draw(absp, AVATAR_HEIGHT); effects.drawabove(absp, alpha); held_effects.drawabove(absp, alpha); @@ -648,6 +655,16 @@ namespace ms guildmark = GuildMark(emblem); } + void Char::set_mini_room(const std::optional &box) + { + miniroom.change(box); + } + + int32_t Char::mini_room_at(Point feet, Point cursor) const + { + return miniroom.serial_at(feet, AVATAR_HEIGHT, cursor); + } + CharLook &Char::get_look() { return look; diff --git a/HeavenClient/Character/Char.h b/HeavenClient/Character/Char.h index ac2f618eb..34e8e195d 100644 --- a/HeavenClient/Character/Char.h +++ b/HeavenClient/Character/Char.h @@ -27,6 +27,7 @@ #include "CharEffect.h" #include "GuildMark.h" +#include "MiniRoomBalloon.h" #include "Inventory/Weapon.h" #include "Look/Afterimage.h" @@ -217,6 +218,14 @@ namespace ms void set_guild_name(const std::string &name); void set_guild_emblem(const GuildEmblem &emblem); + // Shows the balloon of the room the character keeps open, or takes + // it down. + void set_mini_room(const std::optional &box); + + // The room whose balloon is at `cursor`, or 0, with the feet at `feet` + // on the screen. + int32_t mini_room_at(Point feet, Point cursor) const; + // Return if the char is in the Char::State::SIT state bool is_sitting() const; @@ -287,6 +296,7 @@ namespace ms GuildMark guildmark; ChatBalloon chatballoon; ChatBalloon hintballoon; + MiniRoomBalloon miniroom; EffectLayer effects; EffectLayer held_effects; Afterimage afterimage; diff --git a/HeavenClient/Character/Guild.cpp b/HeavenClient/Character/Guild.cpp index c895641bf..b0540ec91 100644 --- a/HeavenClient/Character/Guild.cpp +++ b/HeavenClient/Character/Guild.cpp @@ -96,6 +96,33 @@ namespace ms return exists() && !data.members.empty() && data.members.front().id == cid; } + int32_t Guild::rank(int32_t cid) const + { + const GuildMember* member = find(cid); + + return member ? member->rank : 0; + } + + bool Guild::full() const + { + return data.members.size() >= static_cast(data.capacity); + } + + bool Guild::may_kick(int32_t self, int32_t picked) + { + switch (picked) + { + case 2: + return self == 1; + case 3: + case 4: + case 5: + return self == 1 || self == 2; + default: + return false; + } + } + const std::string& Guild::title(int32_t rank) const { static const std::string none; @@ -134,4 +161,19 @@ namespace ms { data = GuildData(); } + + void Guild::add(GuildMember member) + { + remove(member.id); + data.members.push_back(std::move(member)); + } + + void Guild::remove(int32_t cid) + { + auto& members = data.members; + members.erase(std::remove_if(members.begin(), members.end(), [cid](const GuildMember& member) + { + return member.id == cid; + }), members.end()); + } } diff --git a/HeavenClient/Character/Guild.h b/HeavenClient/Character/Guild.h index cffb03c33..a0bbc3e8c 100644 --- a/HeavenClient/Character/Guild.h +++ b/HeavenClient/Character/Guild.h @@ -81,14 +81,25 @@ namespace ms const GuildMember* find_by_name(const std::string& name) const; // The client takes the first member for the master. bool is_master(int32_t cid) const; + // A member's rank, or 0 for someone outside the guild. + int32_t rank(int32_t cid) const; + bool full() const; // A rank's title, or "" for none. const std::string& title(int32_t rank) const; size_t online_count() const; // Members who would hear guild chat: those online but the player. std::vector online_ids(int32_t self) const; + // Whether a member of rank `self` may expel one of rank `picked`, + // 0 for none, by the guild window's rule (FUN_0090f17c): the master + // expels anyone else, a Jr. Master only those ranked below. + static bool may_kick(int32_t self, int32_t picked); + void update(GuildData data); void clear(); + // Someone joined, or left or was expelled. + void add(GuildMember member); + void remove(int32_t cid); private: GuildData data; diff --git a/HeavenClient/Character/MiniRoomBalloon.cpp b/HeavenClient/Character/MiniRoomBalloon.cpp new file mode 100644 index 000000000..97c5f09e8 --- /dev/null +++ b/HeavenClient/Character/MiniRoomBalloon.cpp @@ -0,0 +1,104 @@ +#include "MiniRoomBalloon.h" + +#include "../Util/Misc.h" +#include "../Util/NxFiles.h" + +#include + +namespace ms +{ + namespace + { + constexpr int8_t PERSONAL_SHOP = 4; + + // The title wraps once at 100 and the rest is cut short + // (FUN_0048dcbd); the seat sign sits in the white box. + constexpr uint16_t TITLE_WIDTH = 100; + constexpr Point FIRST_LINE = Point(8, 8); + constexpr Point SECOND_LINE = Point(8, 22); + constexpr Point SEATS = Point(60, 39); + // v83 draws a line's cap top about 3 below where it is drawn, we 9. + constexpr Point TEXT_Y = Point(0, -6); + + // A bubble floats this far over the head; a signboard stands 2 below + // the feet (FUN_0048e85a). + constexpr int16_t BUBBLE_GAP = 5; + constexpr int16_t SIGN_DROP = 2; + } + + MiniRoomBalloon::MiniRoomBalloon() : shown(false), serial(0), signboard(false) + {} + + void MiniRoomBalloon::change(const std::optional &box) + { + // Only shops have balloons here; games have none yet. + shown = box && box->type == PERSONAL_SHOP; + + if (!shown) + return; + + nl::node miniroom = NxFiles::ui_classic["ChatBalloon.img"]["miniroom"]; + + serial = box->serial; + signboard = box->skin != 0; + skin = miniroom["PSSkin"][std::to_string(box->skin)]; + + if (!skin.is_valid()) + { + signboard = false; + skin = miniroom["PSSkin"]["0"]; + } + + seats = miniroom[box->users < box->max_users ? "Able" : "Disable"]; + + // v83 breaks the title where the first line runs out, not at a word. + Text measure = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK); + size_t fits = 0; + + while (fits < box->title.size()) + { + measure.change_text(box->title.substr(0, fits + 1)); + + if (measure.width() > TITLE_WIDTH) + break; + + fits++; + } + + first = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, box->title.substr(0, fits), 0, false); + second = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, box->title.substr(fits), 0, false); + string_format::format_with_ellipsis(second, TITLE_WIDTH); + } + + void MiniRoomBalloon::draw(Point feet, int16_t avatar_height) const + { + if (!shown) + return; + + Point top_left = bounds(feet, avatar_height).get_left_top(); + // Text and the seat sign are placed from the skin's origin. + Point origin = top_left + skin.get_origin(); + + skin.draw(origin); + seats.draw(origin + SEATS); + first.draw(origin + FIRST_LINE + TEXT_Y); + second.draw(origin + SECOND_LINE + TEXT_Y); + } + + int32_t MiniRoomBalloon::serial_at(Point feet, int16_t avatar_height, Point cursor) const + { + if (shown && bounds(feet, avatar_height).contains(cursor)) + return serial; + + return 0; + } + + Rectangle MiniRoomBalloon::bounds(Point feet, int16_t avatar_height) const + { + Point size = skin.get_dimensions(); + int16_t top = signboard ? SIGN_DROP - size.y() : -size.y() - avatar_height - BUBBLE_GAP; + Point top_left = feet + Point(-size.x() / 2, top); + + return Rectangle(top_left, top_left + size); + } +} diff --git a/HeavenClient/Character/MiniRoomBalloon.h b/HeavenClient/Character/MiniRoomBalloon.h new file mode 100644 index 000000000..0c14d007e --- /dev/null +++ b/HeavenClient/Character/MiniRoomBalloon.h @@ -0,0 +1,42 @@ +#pragma once + +#include "MiniRoomBox.h" + +#include "../Graphics/Text.h" +#include "../Graphics/Texture.h" +#include "../Template/Rectangle.h" + +#include + +namespace ms +{ + // The board over someone keeping a personal shop open (v83 + // FUN_0048bbca): the permit's skin with the store's title and whether a + // seat is free. Double-clicking it visits the shop. + class MiniRoomBalloon + { + public: + MiniRoomBalloon(); + + // Shows `box`, or nothing. + void change(const std::optional &box); + + void draw(Point feet, int16_t avatar_height) const; + + // The room to visit when the balloon is clicked at `cursor`, or 0. + int32_t serial_at(Point feet, int16_t avatar_height, Point cursor) const; + + private: + Rectangle bounds(Point feet, int16_t avatar_height) const; + + bool shown; + int32_t serial; + // Skin 0 is a speech bubble over the head; the others are signboards + // standing on the ground. + bool signboard; + Texture skin; + Texture seats; + Text first; + Text second; + }; +} diff --git a/HeavenClient/Character/MiniRoomBox.h b/HeavenClient/Character/MiniRoomBox.h new file mode 100644 index 000000000..4407b634f --- /dev/null +++ b/HeavenClient/Character/MiniRoomBox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +namespace ms +{ + // The room a character keeps open, as the balloon over them shows it + // (UPDATE_CHAR_BOX and the player spawn, v83 FUN_0048bbca). + struct MiniRoomBox + { + int8_t type = 0; + int32_t serial = 0; + std::string title; + uint8_t skin = 0; + uint8_t users = 0; + uint8_t max_users = 0; + }; +} diff --git a/HeavenClient/Configuration.cpp b/HeavenClient/Configuration.cpp index 2a21e7df0..363122e43 100644 --- a/HeavenClient/Configuration.cpp +++ b/HeavenClient/Configuration.cpp @@ -74,6 +74,7 @@ namespace ms settings.emplace(); settings.emplace(); settings.emplace(); + settings.emplace(); settings.emplace(); settings.emplace(); settings.emplace(); diff --git a/HeavenClient/Configuration.h b/HeavenClient/Configuration.h index 003c13200..6d8d630e5 100644 --- a/HeavenClient/Configuration.h +++ b/HeavenClient/Configuration.h @@ -660,6 +660,13 @@ namespace ms {} }; + // The default position of UIPersonalShop: centred, like the v83 dialog + struct PosPERSONALSHOP : public Configuration::PointEntry + { + PosPERSONALSHOP() : PointEntry("PosPERSONALSHOP", "(137, 23)") + {} + }; + // The default position of UINotice struct PosNOTICE : public Configuration::PointEntry { diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp index 789398c89..9953b0435 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp @@ -59,6 +59,7 @@ namespace ms mapmark = std::string(info["mapMark"]); swim = info["swim"].get_bool(); town = info["town"].get_bool(); + personal_shop = info["personalShop"].get_bool(); recovery = info["recovery"].get_real(1.0); for (auto seat : src["seat"]) @@ -96,6 +97,11 @@ namespace ms return town; } + bool MapInfo::allows_personal_shop() const + { + return personal_shop; + } + int32_t MapInfo::get_fieldlimit() const { return fieldlimit; diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.h b/HeavenClient/Gameplay/MapleMap/MapInfo.h index 77d88d69c..8f79e87c3 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.h +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.h @@ -80,6 +80,10 @@ namespace ms // Whether the map is a town (info/town). bool is_town() const; + // Whether players may open a personal shop here (info/personalShop, + // the Free Market rooms). + bool allows_personal_shop() const; + // The map's fieldLimit bits. int32_t get_fieldlimit() const; @@ -124,6 +128,7 @@ namespace ms std::string mapmark; bool swim; bool town; + bool personal_shop = false; double recovery = 1.0; bool hideminimap; Range mapwalls; diff --git a/HeavenClient/Gameplay/Spawn.cpp b/HeavenClient/Gameplay/Spawn.cpp index 3b0e6838b..0be23fbf9 100644 --- a/HeavenClient/Gameplay/Spawn.cpp +++ b/HeavenClient/Gameplay/Spawn.cpp @@ -114,8 +114,9 @@ namespace ms CharSpawn::CharSpawn(int32_t c, const LookEntry &lk, uint8_t l, int16_t j, const std::string &nm, int8_t st, Point p, int32_t ch, std::vector> bf, - const std::string &gd, const GuildEmblem &em) : cid(c), look(lk), level(l), job(j), name(nm), - stance(st), position(p), chair(ch), buffs(std::move(bf)), guild(gd), emblem(em) + const std::string &gd, const GuildEmblem &em, std::optional rm) : cid(c), look(lk), + level(l), job(j), name(nm), stance(st), position(p), chair(ch), buffs(std::move(bf)), guild(gd), + emblem(em), room(std::move(rm)) {} int32_t CharSpawn::get_cid() const @@ -130,6 +131,7 @@ namespace ms otherchar->show_buffs(buffs); otherchar->set_guild_name(guild); otherchar->set_guild_emblem(emblem); + otherchar->set_mini_room(room); return otherchar; } diff --git a/HeavenClient/Gameplay/Spawn.h b/HeavenClient/Gameplay/Spawn.h index 037fec661..293d59d89 100644 --- a/HeavenClient/Gameplay/Spawn.h +++ b/HeavenClient/Gameplay/Spawn.h @@ -25,11 +25,13 @@ #include "../Character/Buff.h" #include "../Character/Guild.h" +#include "../Character/MiniRoomBox.h" #include "../Graphics/Animation.h" #include "../Net/Login.h" #include "../Net/Handlers/Helpers/MobStatParser.h" #include +#include #include #include @@ -133,7 +135,7 @@ namespace ms CharSpawn(int32_t cid, const LookEntry &look, uint8_t level, int16_t job, const std::string &name, int8_t stance, Point position, int32_t chair, std::vector> buffs, const std::string &guild, - const GuildEmblem &emblem); + const GuildEmblem &emblem, std::optional room = std::nullopt); int32_t get_cid() const; @@ -151,5 +153,6 @@ namespace ms std::vector> buffs; std::string guild; GuildEmblem emblem; + std::optional room; }; } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index 08d30bcc0..a50441b10 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -748,6 +748,27 @@ namespace ms return 0; } + int32_t Stage::mini_room_at(Point cursorpos) + { + if (state != State::ACTIVE) + return 0; + + Point viewpos = camera.position(); + + for (auto &mmo : *chars.get_chars()) + { + Optional other = chars.get_char(mmo.first); + + if (!other || !other->is_active()) + continue; + + if (int32_t serial = other->mini_room_at(other->get_position() + viewpos, cursorpos)) + return serial; + } + + return 0; + } + void Stage::release_key(KeyType::Id type, int32_t action) { if (state != State::ACTIVE) @@ -783,7 +804,13 @@ namespace ms return statusbar->send_cursor(pressed, position); } - return npcs.send_cursor(pressed, position, camera.position()); + Cursor::State npcstate = npcs.send_cursor(pressed, position, camera.position()); + + // A shop's balloon shows the house cursor (FUN_00531cc7). + if (npcstate == Cursor::State::IDLE && mini_room_at(position)) + return Cursor::State::HOUSE; + + return npcstate; } bool Stage::is_player(int32_t cid) const diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index 9f12c7252..672e12a93 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -86,6 +86,10 @@ namespace ms // menu on them, or 0. The player can't be picked. int32_t other_character_at(Point cursorpos); + // The room whose balloon over someone else is under the cursor, or + // 0; our own isn't (FUN_00972853). + int32_t mini_room_at(Point cursorpos); + // Lets go of a held key whose release went elsewhere, such as to a // dialog or a text field, so the player doesn't keep walking. void release_key(KeyType::Id keytype, int32_t keycode); diff --git a/HeavenClient/Graphics/Color.h b/HeavenClient/Graphics/Color.h index 0d22d00d2..3cf797eba 100644 --- a/HeavenClient/Graphics/Color.h +++ b/HeavenClient/Graphics/Color.h @@ -115,6 +115,14 @@ namespace ms MAYABLUE, MAUVE, SCRIPTPURPLE, + PRICEBLUE, + PRICETEAL, + PRICEGREEN, + PRICEORANGE, + PRICEPINK, + PRICEPLUM, + SEATBLUE, + SEATPINK, NUM_COLORS }; @@ -179,7 +187,15 @@ namespace ms {0.39f, 0.71f, 0.94f}, // Picton blue (#64B4F0, v83 font 5: quest messages in the status lines) {0.38f, 0.81f, 1.00f}, // Maya blue (#60CEFF, v83 chat font 10: server notices) {0.88f, 0.67f, 1.00f}, // Mauve (#E1ACFE, v83 chat font 4: guild chat) - {0.32f, 0.22f, 0.55f} // Script purple (#51378C, #d in v83 NPC dialog text) + {0.32f, 0.22f, 0.55f}, // Script purple (#51378C, #d in v83 NPC dialog text) + {0.00f, 0.48f, 0.96f}, // Price blue (#007AF4, v83 font 0x32: personal shop prices under 100,000) + {0.00f, 0.74f, 0.77f}, // Price teal (#00BDC4, v83 font 0x33: prices under 1,000,000) + {0.38f, 0.60f, 0.00f}, // Price green (#629A00, v83 font 0x34: prices under 10,000,000) + {1.00f, 0.33f, 0.00f}, // Price orange (#FF5400, v83 font 0x35: prices under 100,000,000) + {0.82f, 0.13f, 0.76f}, // Price pink (#D022C2, v83 font 0x36: prices under 1,000,000,000) + {0.65f, 0.00f, 0.43f}, // Price plum (#A6006E, v83 font 0x37: higher prices) + {0.00f, 0.40f, 0.60f}, // Seat blue (#006699, v83 font 11: a personal shop's first visitor's chat) + {0.80f, 0.00f, 0.40f} // Seat pink (#CC0066, v83 font 12: the second visitor's chat) }; // Create a color by an array of real numbers [0.0f, 1.0f] diff --git a/HeavenClient/IO/ChatCommand.h b/HeavenClient/IO/ChatCommand.h new file mode 100644 index 000000000..63f5e3218 --- /dev/null +++ b/HeavenClient/IO/ChatCommand.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +namespace ms +{ + // How v83 reads the chat bar's slash commands. + namespace ChatCommand + { + // Whether a and b match without regard to case. + inline bool same_text(const std::string& a, const std::string& b) + { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), + [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); + } + + // Whether input is command followed by a space and something more, + // which becomes rest (FUN_004a8e3d). + inline bool with_argument(const std::string& input, const std::string& command, std::string& rest) + { + std::string prefix = command + " "; + + if (input.size() <= prefix.size() || !same_text(input.substr(0, prefix.size()), prefix)) + return false; + + rest = input.substr(prefix.size()); + return true; + } + } +} diff --git a/HeavenClient/IO/Components/Icon.cpp b/HeavenClient/IO/Components/Icon.cpp index 6f55d17f1..f4abf8e09 100644 --- a/HeavenClient/IO/Components/Icon.cpp +++ b/HeavenClient/IO/Components/Icon.cpp @@ -87,6 +87,21 @@ namespace ms type->drop_on_trade(slot); } + void Icon::drop_on_shop() const + { + type->drop_on_shop(); + } + + InventoryType::Id Icon::get_return_tab() const + { + return type->get_return_tab(); + } + + void Icon::drop_on_inventory() const + { + type->drop_on_inventory(); + } + Keyboard::Mapping Icon::get_mapping() const { return type->get_mapping(); diff --git a/HeavenClient/IO/Components/Icon.h b/HeavenClient/IO/Components/Icon.h index 268508ee0..8073e4db1 100644 --- a/HeavenClient/IO/Components/Icon.h +++ b/HeavenClient/IO/Components/Icon.h @@ -58,6 +58,19 @@ namespace ms // Offers the icon's item in one of my trade slots (1 to 9). virtual void drop_on_trade(int8_t slot) const {} + // Puts the icon's item up for sale in my personal shop. + virtual void drop_on_shop() const {} + + // The inventory tab an icon from another window goes back to, + // or NONE if the inventory doesn't take it. + virtual InventoryType::Id get_return_tab() const + { + return InventoryType::Id::NONE; + } + + // Hands the icon back to the inventory, as a shop row taken back. + virtual void drop_on_inventory() const {} + // What a key bound to this icon would do; NONE if it cannot be // bound. virtual Keyboard::Mapping get_mapping() const @@ -89,6 +102,9 @@ namespace ms bool drop_on_items(InventoryType::Id tab, EquipSlot::Id eqslot, int16_t slot, bool equip) const; void drop_on_bindings(Point cursorposition, bool remove) const; void drop_on_trade(int8_t slot) const; + void drop_on_shop() const; + InventoryType::Id get_return_tab() const; + void drop_on_inventory() const; Keyboard::Mapping get_mapping() const; void set_count(int16_t count); diff --git a/HeavenClient/IO/Components/Tooltip.h b/HeavenClient/IO/Components/Tooltip.h index 70a637971..5ad2c20b4 100644 --- a/HeavenClient/IO/Components/Tooltip.h +++ b/HeavenClient/IO/Components/Tooltip.h @@ -44,7 +44,8 @@ namespace ms MINIMAP, MONSTERBOOK, BUFFLIST, - TRADINGROOM + TRADINGROOM, + PERSONALSHOP }; virtual ~Tooltip() diff --git a/HeavenClient/IO/GuildCommands.cpp b/HeavenClient/IO/GuildCommands.cpp new file mode 100644 index 000000000..75be046e2 --- /dev/null +++ b/HeavenClient/IO/GuildCommands.cpp @@ -0,0 +1,138 @@ +#include "GuildCommands.h" + +#include "ChatCommand.h" +#include "UI.h" + +#include "UITypes/UIChatBar.h" +#include "UITypes/UINotice.h" + +#include "../Gameplay/Stage.h" +#include "../Net/Packets/GameplayPackets.h" + +namespace ms +{ + namespace GuildCommands + { + namespace + { + constexpr char NOT_IN_GUILD[] = "You are not in the guild."; + constexpr int32_t MASTER = 1; + constexpr int32_t JR_MASTER = 2; + + void notice(const std::string& line) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(line, UIChatBar::LineType::SKILL); + } + } + + bool run(const std::string& input) + { + std::string name; + + if (ChatCommand::same_text(input, "/guildinfo")) + show_info(); + else if (ChatCommand::same_text(input, "/leaveguild")) + leave(); + else if (ChatCommand::with_argument(input, "/guildinvite", name)) + invite(name); + else if (ChatCommand::with_argument(input, "/guildexpel", name)) + expel(name); + else + return false; + + return true; + } + + void show_info() + { + const Player& player = Stage::get().get_player(); + const Guild& guild = player.get_guild(); + + if (!guild.exists()) + return notice(NOT_IN_GUILD); + + // Everyone, online or not. + std::string line = "Guild Member:"; + + for (auto& member : guild.get_data().members) + line += " " + member.name; + + notice(line); + + if (guild.is_master(player.get_oid())) + notice("You are the guild master."); + } + + void leave() + { + const Player& player = Stage::get().get_player(); + const Guild& guild = player.get_guild(); + + if (!guild.exists()) + notice(NOT_IN_GUILD); + else if (guild.is_master(player.get_oid())) + notice("You cannot quit the guild, since you are the master of the guild."); + else + UI::get().emplace("Would you like to quit the guild?", [](bool yes) + { + const Player& player = Stage::get().get_player(); + + if (yes) + LeaveGuildPacket(player.get_oid(), player.get_stats().get_name()).dispatch(); + }); + } + + void invite(const std::string& name) + { + const Player& player = Stage::get().get_player(); + const Guild& guild = player.get_guild(); + + if (name == player.get_stats().get_name()) + notice("You cannot invite yourself to the guild."); + else if (!guild.exists()) + notice(NOT_IN_GUILD); + else if (!can_invite(player)) + notice("You are not the master of the guild."); + else if (guild.full()) + notice("You cannot invite more people to the guild."); + else + InviteToGuildPacket(name).dispatch(); + } + + // Unlike the guild tab, the command leaves it to the server whether + // the player may expel at all. + void expel(const std::string& name) + { + const Player& player = Stage::get().get_player(); + const Guild& guild = player.get_guild(); + + if (!guild.exists()) + return notice(NOT_IN_GUILD); + + const GuildMember* member = guild.find_by_name(name); + + if (!member) + notice("'" + name + "' is not the member of the guild."); + else if (guild.rank(player.get_oid()) == JR_MASTER && member->rank == JR_MASTER) + notice("Only Guild Master can expel Jr.Masters."); + else if (member->rank == MASTER) + notice("You may not ban the Guild Master."); + else + UI::get().emplace("Expel '" + member->name + "' from the guild?", + [cid = member->id, target = member->name](bool yes) + { + if (yes) + ExpelFromGuildPacket(cid, target).dispatch(); + }); + } + + bool can_invite(const Player& player) + { + const Guild& guild = player.get_guild(); + int32_t self = player.get_oid(); + + return guild.exists() && (guild.is_master(self) || guild.rank(self) == JR_MASTER); + } + } +} diff --git a/HeavenClient/IO/GuildCommands.h b/HeavenClient/IO/GuildCommands.h new file mode 100644 index 000000000..3aabacc99 --- /dev/null +++ b/HeavenClient/IO/GuildCommands.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace ms +{ + class Player; + + // v83's guild commands, with the checks the client makes before asking + // the server. The guild tab's buttons and the context menu share them. + namespace GuildCommands + { + // Runs input if it is a guild command; returns whether it was. They + // are matched as the party commands are. + bool run(const std::string& input); + + // /guildinfo (FUN_00a39033) + void show_info(); + // /leaveguild (FUN_005308e0) + void leave(); + // /guildinvite (FUN_005306d5) + void invite(const std::string& name); + // /guildexpel (FUN_00530a0d) + void expel(const std::string& name); + + // The guild master and the Jr. Masters invite (FUN_0053088f). + bool can_invite(const Player& player); + } +} diff --git a/HeavenClient/IO/PartyCommands.cpp b/HeavenClient/IO/PartyCommands.cpp index d61521e8d..5a8f71280 100644 --- a/HeavenClient/IO/PartyCommands.cpp +++ b/HeavenClient/IO/PartyCommands.cpp @@ -1,5 +1,6 @@ #include "PartyCommands.h" +#include "ChatCommand.h" #include "UI.h" #include "UITypes/UIChatBar.h" @@ -7,9 +8,6 @@ #include "../Gameplay/Stage.h" #include "../Net/Packets/GameplayPackets.h" -#include -#include - namespace ms { namespace PartyCommands @@ -22,25 +20,6 @@ namespace ms // The party holds six. constexpr size_t CAPACITY = 6; - bool same_text(const std::string& a, const std::string& b) - { - return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), - [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); - } - - // Whether input is command followed by a space and something - // more, which becomes rest (FUN_004a8e3d). - bool with_argument(const std::string& input, const std::string& command, std::string& rest) - { - std::string prefix = command + " "; - - if (input.size() <= prefix.size() || !same_text(input.substr(0, prefix.size()), prefix)) - return false; - - rest = input.substr(prefix.size()); - return true; - } - void notice(const std::string& line) { if (auto chatbar = UI::get().get_element()) @@ -61,6 +40,9 @@ namespace ms bool run(const std::string& input) { + using ChatCommand::same_text; + using ChatCommand::with_argument; + std::string name; if (same_text(input, "/partyinfo")) diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index 938224446..c990b912e 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -108,6 +108,11 @@ namespace ms return !quitted; } + bool UI::modal_open() const + { + return state->modal_open(); + } + void UI::send_cursor(Point cursorpos, Cursor::State cursorstate) { Cursor::State nextstate = state->send_cursor(cursorstate, cursorpos); @@ -331,6 +336,9 @@ namespace ms types.emplace_back(UIElement::Type::NPCTALK); types.emplace_back(UIElement::Type::CHARINFO); types.emplace_back(UIElement::Type::TRADINGROOM); + types.emplace_back(UIElement::Type::PERSONALSHOP); + types.emplace_back(UIElement::Type::SHOPSELL); + types.emplace_back(UIElement::Type::SHOPSALES); } else if (enter) { diff --git a/HeavenClient/IO/UI.h b/HeavenClient/IO/UI.h index c4be0cb68..bc58ee93b 100644 --- a/HeavenClient/IO/UI.h +++ b/HeavenClient/IO/UI.h @@ -56,6 +56,9 @@ namespace ms bool not_quitted() const; + // Whether a window that holds the cursor is open. + bool modal_open() const; + void send_cursor(Point pos); void send_cursor(bool pressed); diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index a4d5e2409..fbb902b85 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -81,6 +81,9 @@ namespace ms REVIVE, TRADINGROOM, INVITECARD, + PERSONALSHOP, + SHOPSELL, + SHOPSALES, CONTEXTMENU, NUM_TYPES }; diff --git a/HeavenClient/IO/UIState.h b/HeavenClient/IO/UIState.h index 56d30b1d1..56a5d33af 100644 --- a/HeavenClient/IO/UIState.h +++ b/HeavenClient/IO/UIState.h @@ -87,6 +87,10 @@ namespace ms virtual UIElement* get_front(std::list types) = 0; virtual UIElement* get_front(Point pos) = 0; + + // Whether a window that holds the cursor, such as a notice or an + // NPC's talk, is open. + virtual bool modal_open() const { return false; } }; class UIStateNull : public UIState diff --git a/HeavenClient/IO/UIStateGame.cpp b/HeavenClient/IO/UIStateGame.cpp index 973d361e5..e964ef0f7 100644 --- a/HeavenClient/IO/UIStateGame.cpp +++ b/HeavenClient/IO/UIStateGame.cpp @@ -47,6 +47,7 @@ #include "UITypes/UIWorldMap.h" #include "../Net/Packets/GameplayPackets.h" +#include "../Net/Packets/MiniRoomPackets.h" #include "../Net/Packets/PlayerInteractionPackets.h" namespace ms @@ -69,6 +70,8 @@ namespace ms return Tooltip::Parent::STORAGE; case UIElement::Type::TRADINGROOM: return Tooltip::Parent::TRADINGROOM; + case UIElement::Type::PERSONALSHOP: + return Tooltip::Parent::PERSONALSHOP; case UIElement::Type::EVENT: return Tooltip::Parent::EVENT; case UIElement::Type::KEYCONFIG: @@ -216,6 +219,9 @@ namespace ms { if (UIElement *front = get_front(pos)) front->doubleclick(pos); + // Someone's shop balloon comes before them (FUN_0094fbbf). + else if (int32_t serial = Stage::get().mini_room_at(pos)) + MiniRoomVisitPacket(serial).dispatch(); else if (int32_t cid = Stage::get().character_at(pos)) { // v83 asks only when it may ask the server, as for an item @@ -714,6 +720,13 @@ namespace ms return elements[type].get(); } + bool UIStateGame::modal_open() const + { + const UIElement::UPtr& element = elements[focused]; + + return focused != UIElement::Type::NONE && element && element->is_active(); + } + UIElement *UIStateGame::get_front(std::list types) { auto begin = elementorder.rbegin(); diff --git a/HeavenClient/IO/UIStateGame.h b/HeavenClient/IO/UIStateGame.h index cbe051277..458a5cdc4 100644 --- a/HeavenClient/IO/UIStateGame.h +++ b/HeavenClient/IO/UIStateGame.h @@ -86,6 +86,7 @@ namespace ms UIElement *get_front(Point pos) override; + bool modal_open() const override; private: const CharStats &stats; diff --git a/HeavenClient/IO/UITypes/EnterNumber.h b/HeavenClient/IO/UITypes/EnterNumber.h index af37b6a62..7169f24c1 100644 --- a/HeavenClient/IO/UITypes/EnterNumber.h +++ b/HeavenClient/IO/UITypes/EnterNumber.h @@ -48,5 +48,21 @@ namespace ms return { Problem::NONE, static_cast(number) }; } + + // The notice v83 shows for a problem (StringPool 0x331..0x333), or nothing. + inline std::string problem_text(Problem problem, int32_t max, int32_t min = 1) + { + switch (problem) + { + case Problem::NOT_A_NUMBER: + return "Only numbers are allowed."; + case Problem::TOO_LOW: + return "You may only enter a number \\nequal to or higher than " + std::to_string(min) + "."; + case Problem::TOO_HIGH: + return "You may only enter a number \\nequal to or lower than " + std::to_string(max) + "."; + default: + return ""; + } + } } } diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index 66fcd2993..1b2afdf22 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -28,6 +28,7 @@ #include "../Components/MapleButton.h" +#include "../GuildCommands.h" #include "../PartyCommands.h" #include "../../Configuration.h" @@ -289,6 +290,9 @@ namespace ms else if (PartyCommands::run(msg)) { } + else if (GuildCommands::run(msg)) + { + } // Commands the client doesn't know aren't chat, whatever // the target; v83 sends them only for GMs. else if (msg[0] == '/') diff --git a/HeavenClient/IO/UITypes/UIContextMenu.cpp b/HeavenClient/IO/UITypes/UIContextMenu.cpp index 1bf2a03ba..bf82bce75 100644 --- a/HeavenClient/IO/UITypes/UIContextMenu.cpp +++ b/HeavenClient/IO/UITypes/UIContextMenu.cpp @@ -5,6 +5,7 @@ #include "UINotice.h" #include "UITradingRoom.h" +#include "../GuildCommands.h" #include "../PartyCommands.h" #include "../UI.h" #include "../Window.h" @@ -39,45 +40,12 @@ namespace ms const Point TITLE_AT(11, -3); constexpr size_t TITLE_WIDTH = 78; - void notice(const std::string& line) - { - if (auto chatbar = UI::get().get_element()) - chatbar->send_chatline(line, UIChatBar::LineType::SKILL); - } - // Juniors come from the family, which HC doesn't have. bool can_add_junior() { return false; } - // The guild master and the Jr. Masters (rank 2) invite (FUN_0053088f). - bool can_invite_to_guild(const Player& player) - { - const Guild& guild = player.get_guild(); - int32_t self = player.get_oid(); - const GuildMember* member = guild.find(self); - - return guild.exists() && (guild.is_master(self) || (member && member->rank == 2)); - } - - // /guildinvite's checks (FUN_005306d5). - void invite_to_guild(const std::string& name) - { - const Player& player = Stage::get().get_player(); - const Guild& guild = player.get_guild(); - - if (name == player.get_stats().get_name()) - notice("You cannot invite yourself to the guild."); - else if (!guild.exists()) - notice("You are not in the guild."); - else if (!can_invite_to_guild(player)) - notice("You are not the master of the guild."); - else if (guild.get_data().members.size() >= static_cast(guild.get_data().capacity)) - notice("You cannot invite more people to the guild."); - else - InviteToGuildPacket(name).dispatch(); - } } UIContextMenu::UIContextMenu(int32_t c, const std::string& n, Point cursor) : @@ -95,7 +63,7 @@ namespace ms bool party_invite = !party.exists() || party.is_leader(player.get_oid()); std::vector entries = - context_menu::entries(party_invite, can_invite_to_guild(player), can_add_junior()); + context_menu::entries(party_invite, GuildCommands::can_invite(player), can_add_junior()); rows = entries.size(); for (size_t row = 0; row < rows; row++) @@ -190,7 +158,7 @@ namespace ms PartyCommands::invite(name); break; case context_menu::GUILD: - invite_to_guild(name); + GuildCommands::invite(name); break; case context_menu::CLAIM: // The server never opens reports, so v83 says they're down diff --git a/HeavenClient/IO/UITypes/UIInviteCard.cpp b/HeavenClient/IO/UITypes/UIInviteCard.cpp index 1d27dd869..527470b82 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.cpp +++ b/HeavenClient/IO/UITypes/UIInviteCard.cpp @@ -32,6 +32,8 @@ namespace ms const char* heading; // v83's text position (FUN_0052065d), moved up to HC's text tops. Point text; + // The guild's has "from ''" first. + bool from_first = false; }; // The invitations'. @@ -39,7 +41,8 @@ namespace ms { "icon0", "An invitation", Point(24, 2) }, { "icon1", "Buddy request", Point(24, 2) }, { "icon2", "Trade request", Point(24, 2) }, - { "icon5", "Party invite", Point(26, 4) } + { "icon5", "Party invite", Point(26, 4) }, + { "icon5", "Guild invite", Point(26, 4), true } }; // v83 writes the arrivals at (25, 7), in Arial 11. @@ -112,12 +115,13 @@ namespace ms { const Look& look = LOOKS[kind]; nl::node fade = NxFiles::ui_classic["UIWindow.img"]["FadeYesNo"]; + std::string from = "from '" + inviter + "'"; push({ kind, fade[look.icon], - Text(Text::A12M, Text::LEFT, Color::Name::WHITE, look.heading), - Text(Text::A12M, Text::LEFT, Color::Name::WHITE, "from '" + inviter + "'"), + Text(Text::A12M, Text::LEFT, Color::Name::WHITE, look.from_first ? from : look.heading), + Text(Text::A12M, Text::LEFT, Color::Name::WHITE, look.from_first ? look.heading : from), look.text, std::move(answer), FadeNotice(LIFETIME) diff --git a/HeavenClient/IO/UITypes/UIInviteCard.h b/HeavenClient/IO/UITypes/UIInviteCard.h index d71088c63..6dd50c3fd 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.h +++ b/HeavenClient/IO/UITypes/UIInviteCard.h @@ -15,7 +15,7 @@ namespace ms { // v83's fading invitations above the status bar (UIWindow.img/FadeYesNo, // FUN_0052065d): an invitation to Maple Messenger, a buddy request, a - // trade request or a party invite, each "from ''". O accepts and X + // trade request, a party invite or a guild invite, each "from ''". O accepts and X // turns it down; one left alone fades out after three minutes and sends // nothing. Smaller pink ones say a buddy logged in or came to the // player's channel, for six seconds, with only an X. A new one replaces @@ -29,8 +29,8 @@ namespace ms static constexpr bool FOCUSED = false; static constexpr bool TOGGLED = false; - // v83's notice types 0, 1, 2, 5 and 9. - enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, ARRIVAL, NUM_KINDS }; + // v83's notice types 0, 1, 2, 5, 8 and 9. + enum Kind : uint8_t { MESSENGER, BUDDY, TRADE, PARTY, GUILD, ARRIVAL, NUM_KINDS }; UIInviteCard(); diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 3a4aa94ad..a3bf43419 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -18,6 +18,7 @@ #include "UIItemInventory.h" #include "UINotice.h" +#include "UIPersonalShop.h" #include "../Messages.h" #include "../UI.h" @@ -378,6 +379,8 @@ namespace ms // used up. if (item_id / 10000 == 516) Stage::get().send_key(KeyType::Id::FACE, 99 + item_id % 100 + 8, true); + else if (personal_shop::is_permit(item_id)) + UIPersonalShop::create(slot, item_id); break; } @@ -387,6 +390,22 @@ namespace ms bool UIItemInventory::send_icon(const Icon &icon, Point cursorpos) { + // A row of my shop goes back anywhere on the window, once it shows + // the row's tab (FUN_004f4b49). + if (InventoryType::Id back = icon.get_return_tab(); back != InventoryType::Id::NONE) + { + if (back != tab) + { + change_tab(back); + + return false; + } + + icon.drop_on_inventory(); + + return true; + } + int16_t slot = slot_by_position(cursorpos - position); if (slot > 0) @@ -802,6 +821,20 @@ namespace ms } } + void UIItemInventory::ItemIcon::drop_on_shop() const + { + // v83 refuses these before asking anything (FUN_004f41b9). + if (questitem || untradable || cashitem) + { + UI::get().emplace("This item is unavailable to trade with others.", [](bool) {}); + + return; + } + + if (auto shop = UI::get().get_element()) + shop->sell(sourcetab, source, item_id, count); + } + void UIItemInventory::ItemIcon::set_count(int16_t c) { count = c; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.h b/HeavenClient/IO/UITypes/UIItemInventory.h index 175be5baa..1ceeaaec3 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.h +++ b/HeavenClient/IO/UITypes/UIItemInventory.h @@ -112,6 +112,8 @@ namespace ms void drop_on_trade(int8_t slot) const override; + void drop_on_shop() const override; + void set_count(int16_t count) override; Icon::IconType get_type() override; diff --git a/HeavenClient/IO/UITypes/UINotice.cpp b/HeavenClient/IO/UITypes/UINotice.cpp index 17932e481..970163ca8 100644 --- a/HeavenClient/IO/UITypes/UINotice.cpp +++ b/HeavenClient/IO/UITypes/UINotice.cpp @@ -304,28 +304,17 @@ namespace ms }; enter_number::Checked checked = enter_number::check(numstr, max, min); - std::string problem; - switch (checked.problem) + if (checked.problem == enter_number::Problem::NONE) { - case enter_number::Problem::NONE: - numhandler(checked.number); - close(); - buttons[Buttons::OK]->set_state(Button::State::NORMAL); - return; - case enter_number::Problem::NOT_A_NUMBER: - problem = "Only numbers are allowed."; - break; - case enter_number::Problem::TOO_LOW: - problem = "You may only enter a number \\nequal to or higher than " + std::to_string(min) + "."; - break; - case enter_number::Problem::TOO_HIGH: - problem = "You may only enter a number \\nequal to or lower than " + std::to_string(max) + "."; - break; + numhandler(checked.number); + close(); + buttons[Buttons::OK]->set_state(Button::State::NORMAL); + return; } numfield.set_state(Textfield::State::DISABLED); - UI::get().emplace(problem, okhandler); + UI::get().emplace(enter_number::problem_text(checked.problem, max, min), okhandler); } void UIEnterNumber::close() diff --git a/HeavenClient/IO/UITypes/UIPersonalShop.cpp b/HeavenClient/IO/UITypes/UIPersonalShop.cpp new file mode 100644 index 000000000..75aa05b56 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIPersonalShop.cpp @@ -0,0 +1,1200 @@ +#include "UIPersonalShop.h" + +#include "UICharInfo.h" +#include "UIChatBar.h" +#include "UIItemInventory.h" +#include "UINotice.h" + +#include "../UI.h" + +#include "../Components/Charset.h" +#include "../Components/MapleButton.h" + +#include "../../Character/ExclRequest.h" +#include "../../Constants.h" +#include "../../Data/CurseData.h" +#include "../../Data/ItemData.h" +#include "../../Gameplay/Stage.h" +#include "../../Graphics/Geometry.h" +#include "../../Net/Packets/MiniRoomPackets.h" +#include "../../Net/Packets/PlayerInteractionPackets.h" +#include "../../Util/Misc.h" +#include "../../Util/NxFiles.h" + +#include +#include + +namespace ms +{ + namespace + { + using personal_shop::OWNER; + using personal_shop::SEATS; + + // The v83 dialog's controls and drawing (FUN_006fa4b2, FUN_006fb99d). + constexpr Point BT_TOP = Point(144, 62); + constexpr Point BT_MIDDLE = Point(144, 90); + constexpr Point BT_BOTTOM = Point(144, 110); + constexpr Point BT_ENTER = Point(482, 321); + constexpr Point BT_CLAIM = Point(498, 343); + constexpr int16_t BT_BAN_Y = 117; + + // v83 draws a line's cap top about 3 below where it is drawn, we 9, + // so all text here goes up by the difference. + constexpr Point TEXT_Y = Point(0, -6); + + constexpr Point TITLE = Point(23, 9); + constexpr size_t TITLE_WIDTH = 496; + constexpr Point MESO = Point(218, 135); + + // The owner stands on the left facing right, the visitors to the + // right of them facing left; names go on the tags below. + constexpr Point OWNER_FEET = Point(53, 127); + constexpr int16_t OWNER_NAME_X = 56; + constexpr int16_t FEET_Y = 127; + constexpr int16_t NAME_Y = 135; + constexpr int16_t SEAT_PITCH = 95; + + int16_t seat_x(uint8_t slot) + { + return (slot + 2) * SEAT_PITCH; + } + + // Five rows of items, 40 apart. + constexpr int16_t ROW_TOP = 157; + constexpr int16_t ROW_PITCH = 40; + constexpr int16_t ROWS = 5; + constexpr int16_t ROW_LEFT = 7; + constexpr Point ROW_SIZE = Point(199, 35); + constexpr Point SELECT = Point(44, 0); + constexpr Point ICON = Point(8, 34); + constexpr Point COUNT = Point(8, 23); + constexpr Point NAME = Point(48, 1); + constexpr Point PRICE = Point(48, 19); + constexpr size_t NAME_WIDTH = 152; + constexpr int16_t ITEMS_X = 211; + const Range ITEMS_RANGE = Range(158, 355); + + // The chat log: nine rows of 16, wrapped to 243. + constexpr int16_t LOG_LEFT = 241; + constexpr int16_t LOG_TOP = 162; + constexpr int16_t LOG_ROW = 16; + constexpr int16_t LOG_ROWS = 9; + constexpr int16_t LOG_WIDTH = 243; + constexpr size_t LOG_LIMIT = 200; + constexpr int16_t LOG_CLIP_TOP = 160; + constexpr int16_t LOG_CLIP_BOTTOM = 321; + constexpr int16_t CHAT_X = 502; + const Range CHAT_RANGE = Range(157, 318); + + const Rectangle INPUT = Rectangle(238, 469, 323, 338); + constexpr size_t INPUT_LIMIT = 70; + + // Clicks pass through the empty corner right of the fee table. + constexpr Point CLEAR_CORNER = Point(231, 367); + constexpr int16_t DRAG_HEIGHT = 156; + + // A line takes the colour of its speaker's name tag. + constexpr Color::Name SEAT_COLORS[SEATS] = { + Color::Name::BLACK, Color::Name::SEATBLUE, Color::Name::SEATPINK, Color::Name::DEEPGREEN + }; + + constexpr Color::Name PRICE_COLORS[] = { + Color::Name::PRICEBLUE, Color::Name::PRICETEAL, Color::Name::PRICEGREEN, + Color::Name::PRICEORANGE, Color::Name::PRICEPINK, Color::Name::PRICEPLUM + }; + + const std::string FULL = "You can't sell it anymore."; + const std::string TOO_PRICEY = "The total price for the items exceeded\\nthe maximum amount of mesos that can be sold."; + + Point centred(Point dimension) + { + return Point( + (Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, + (Constants::Constants::get().get_viewheight() - dimension.y()) / 2 + ); + } + + void notice(const std::string &text) + { + UI::get().emplace(text, [](bool) {}); + } + } + + UIPersonalShop::UIPersonalShop(const mini_room::Enter &enter) : UIDragElement(), + own_slot(enter.own_slot < SEATS ? enter.own_slot : OWNER), max_items(enter.max_items), open(own_slot != OWNER), selected(-1), + first_row(0), log_rows(0), log_row(0) + { + nl::node PersonalShop = NxFiles::ui_classic["UIWindow.img"]["PersonalShop"]; + nl::node Basic = NxFiles::ui_classic["Basic.img"]; + + backgrnd = PersonalShop["backgrnd6"]; + select = PersonalShop["select"]; + + // Each side gets only its own buttons (FUN_006fc46b). + if (is_owner()) + { + buttons[Buttons::ITEM] = std::make_unique(PersonalShop["BtItem"], BT_TOP); + buttons[Buttons::START] = std::make_unique(PersonalShop["BtStart"], BT_MIDDLE); + buttons[Buttons::CLOSE] = std::make_unique(PersonalShop["BtClose"], BT_BOTTOM); + + // A ban button shows over each visitor while they stay. + for (uint8_t slot = 1; slot < SEATS; slot++) + { + uint16_t id = Buttons::BAN1 + slot - 1; + buttons[id] = std::make_unique(PersonalShop["BtBan"], Point(seat_x(slot) + 30, BT_BAN_Y)); + buttons[id]->set_active(false); + } + } else + { + buttons[Buttons::INFO] = std::make_unique(PersonalShop["BtInfo"], BT_TOP); + buttons[Buttons::BUY] = std::make_unique(PersonalShop["BtBuy"], BT_MIDDLE); + buttons[Buttons::EXIT] = std::make_unique(PersonalShop["BtExit"], BT_BOTTOM); + } + + buttons[Buttons::ENTER] = std::make_unique(PersonalShop["BtEnter"], BT_ENTER); + buttons[Buttons::CLAIM] = std::make_unique(Basic["BtClaim"], BT_CLAIM); + + title = Text(Text::Font::A12B, Text::Alignment::LEFT, Color::Name::BLACK, enter.title); + string_format::format_with_ellipsis(title, TITLE_WIDTH); + + meso = Text(Text::Font::A12M, Text::Alignment::RIGHT, Color::Name::BLACK); + + for (Member &member : members) + member.name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::WHITE); + + for (const mini_room::Member &member : enter.members) + visit(member); + + items = Slider( + Slider::Type::CLASSIC, ITEMS_RANGE, ITEMS_X, ROWS, 0, + [&](bool upwards) + { + int16_t shift = upwards ? -1 : 1; + + if (first_row + shift >= 0 && first_row + shift + ROWS <= static_cast(listings.size())) + first_row += shift; + } + ); + + chat = Slider( + Slider::Type::CLASSIC, CHAT_RANGE, CHAT_X, LOG_ROWS, 0, + [&](bool upwards) + { + int16_t shift = upwards ? -1 : 1; + + if (log_row + shift >= 0 && log_row + shift + LOG_ROWS <= log_rows) + log_row += shift; + } + ); + + input = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, INPUT, INPUT_LIMIT); + + input.set_enter_callback( + [&](std::string text) + { + input.change_text(""); + submit(text); + } + ); + + input.set_key_callback( + KeyAction::Id::ESCAPE, + [&]() + { + input.set_state(Textfield::State::DISABLED); + input.set_state(Textfield::State::NORMAL); + } + ); + + set_listings(enter.listings); + + dimension = backgrnd.get_dimensions(); + dragarea = Point(dimension.x(), DRAG_HEIGHT); + } + + void UIPersonalShop::draw(float inter) const + { + static const Charset countset = Charset(NxFiles::ui_classic["Basic.img"]["ItemNo"], Charset::Alignment::LEFT); + + backgrnd.draw(position); + title.draw(position + TITLE + TEXT_Y); + + for (uint8_t slot = 0; slot < SEATS; slot++) + { + const Member &member = members[slot]; + + if (!member.present) + continue; + + // Our own avatar follows our equipment as it changes. + const CharLook &look = slot == own_slot ? Stage::get().get_player().get_look() : member.look; + + if (slot == OWNER) + { + look.draw(position + OWNER_FEET, true, Stance::Id::STAND1, Expression::Id::DEFAULT); + member.name.draw(position + Point(OWNER_NAME_X, NAME_Y) + TEXT_Y); + } else + { + look.draw(position + Point(seat_x(slot), FEET_Y), false, Stance::Id::STAND1, Expression::Id::DEFAULT); + member.name.draw(position + Point(seat_x(slot) - 2, NAME_Y) + TEXT_Y); + } + } + + meso.draw(position + MESO + TEXT_Y); + + for (int16_t k = 0; k < ROWS; k++) + { + auto index = static_cast(first_row + k); + + if (index >= listings.size()) + break; + + const mini_room::Listing &listing = listings[index]; + int32_t itemid = listing.item.itemid; + Point row = position + Point(0, ROW_TOP + ROW_PITCH * k); + + // A sold out row stays selected but loses its highlight. + if (static_cast(index) == selected && listing.bundles != 0) + select.draw(row + SELECT); + + ItemData::get(itemid).get_icon(false).draw(row + ICON); + + if (listing.bundles == 0) + ColorBox(ROW_SIZE.x(), ROW_SIZE.y(), Color::Name::WHITE, 0.5f).draw(row + Point(ROW_LEFT, 0)); + + if (personal_shop::shows_count(itemid)) + countset.draw(std::to_string(listing.bundles * listing.per_bundle), row + COUNT); + + rows[index].name.draw(row + NAME + TEXT_Y); + rows[index].price.draw(row + PRICE + TEXT_Y); + } + + items.draw(position); + + Range clip = Range(position.y() + LOG_CLIP_TOP, position.y() + LOG_CLIP_BOTTOM); + int16_t y = LOG_TOP - log_row * LOG_ROW; + + for (const Line &line : log) + { + if (y + line.rows * LOG_ROW > LOG_TOP && y < LOG_TOP + LOG_ROWS * LOG_ROW) + line.text.draw(DrawArgument(position + Point(LOG_LEFT, y) + TEXT_Y), clip); + + y += line.rows * LOG_ROW; + } + + chat.draw(position); + input.draw(position + TEXT_Y); + + UIElement::draw_buttons(inter); + } + + void UIPersonalShop::update() + { + UIElement::update(); + + meso.change_text(mini_room::meso_text(Stage::get().get_player().get_inventory().get_meso())); + input.update(position); + } + + void UIPersonalShop::remove_cursor() + { + UIDragElement::remove_cursor(); + + items.remove_cursor(); + chat.remove_cursor(); + clear_tooltip(); + } + + Cursor::State UIPersonalShop::send_cursor(bool clicked, Point cursorpos) + { + Cursor::State dstate = UIDragElement::send_cursor(clicked, cursorpos); + + if (dragged) + { + clear_tooltip(); + + return dstate; + } + + Point cursor = cursorpos - position; + last_cursor = cursor; + + for (Slider *slider : { &items, &chat }) + { + if (Cursor::State sstate = slider->send_cursor(cursor, clicked)) + { + clear_tooltip(); + + return sstate; + } + } + + Cursor::State tstate = input.send_cursor(cursorpos, clicked); + + if (tstate != Cursor::State::IDLE) + return tstate; + + int16_t index = row_at(cursor); + + if (index < 0) + { + clear_tooltip(); + + return dstate; + } + + const mini_room::Listing &listing = listings[index]; + + // Sold out rows still show what they were (FUN_006fb864). + UI::get().show_item(Tooltip::Parent::PERSONALSHOP, listing.item.itemid); + + if (clicked) + { + selected = index; + + // Before opening, the owner can take a row back by dragging it + // to the inventory (FUN_006fb368). + if (is_owner() && !open && listing.bundles >= 1) + { + Point top_left = Point(ICON.x(), ROW_TOP + ROW_PITCH * (index - first_row) + ICON.y() - 32); + const Texture &icon = ItemData::get(listing.item.itemid).get_icon(false); + + dragged_row = Icon(std::make_unique(index, listing.item.itemid), icon, -1); + dragged_row.start_drag(cursor - top_left); + UI::get().drag_icon(&dragged_row); + + clear_tooltip(); + + return Cursor::State::GRABBING; + } + } + + return dstate; + } + + bool UIPersonalShop::send_icon(const Icon &icon, Point cursorpos) + { + // Only the owner's own inventory items go in; the item checks follow + // in the icon (FUN_004f41b9). + if (is_owner()) + icon.drop_on_shop(); + + return true; + } + + void UIPersonalShop::doubleclick(Point cursorpos) + { + int16_t index = row_at(cursorpos - position); + + if (index >= 0) + buy(index); + } + + void UIPersonalShop::send_scroll(double yoffset) + { + if (last_cursor.x() > ITEMS_X + 20) + chat.send_scroll(yoffset); + else + items.send_scroll(yoffset); + } + + void UIPersonalShop::send_key(int32_t, bool pressed, bool escape) + { + if (pressed && escape) + exit(); + } + + bool UIPersonalShop::is_in_range(Point cursorpos) const + { + Point at = cursorpos - position; + + if (at.x() > CLEAR_CORNER.x() && at.y() > CLEAR_CORNER.y()) + return false; + + return UIElement::is_in_range(cursorpos); + } + + UIElement::Type UIPersonalShop::get_type() const + { + return TYPE; + } + + void UIPersonalShop::visit(const mini_room::Member &member) + { + if (member.slot >= members.size()) + return; + + Member &seat = members[member.slot]; + seat.present = true; + seat.look = CharLook(member.look); + seat.name.change_text(member.name); + + if (member.slot != OWNER && is_owner()) + buttons[Buttons::BAN1 + member.slot - 1]->set_active(true); + } + + void UIPersonalShop::seat_left(uint8_t slot) + { + if (slot == OWNER || slot >= members.size()) + return; + + members[slot].present = false; + members[slot].name.change_text(""); + + if (is_owner()) + buttons[Buttons::BAN1 + slot - 1]->set_active(false); + } + + void UIPersonalShop::change_look(uint8_t slot, const LookEntry &look) + { + if (slot < members.size() && slot != own_slot && members[slot].present) + members[slot].look = CharLook(look); + } + + void UIPersonalShop::add_chat(uint8_t slot, const std::string &line) + { + Color::Name color = SEAT_COLORS[slot < SEATS ? slot : OWNER]; + + add_line(slot == own_slot ? line : mini_room::filter_chat(line, CurseData::filter), color); + } + + void UIPersonalShop::set_listings(const std::vector &list) + { + listings = list; + + refresh_rows(); + } + + void UIPersonalShop::sold(const personal_shop::SoldNotice ¬ice) + { + if (notice.index >= listings.size()) + return; + + sales.add(listings[notice.index], notice.bundles, notice.buyer); + + if (auto dialog = UI::get().get_element(); dialog && dialog->is_active()) + dialog->refresh(); + } + + void UIPersonalShop::buy_result(int8_t result) + { + std::string text = personal_shop::buy_result_text(result); + + if (!text.empty()) + notice(text); + } + + void UIPersonalShop::leave(int8_t reason) + { + std::string text = personal_shop::leave_text(reason); + + close(); + + if (!text.empty()) + notice(text); + } + + void UIPersonalShop::close() + { + clear_tooltip(); + UI::get().cancel_drag(); + + if (input.get_state() == Textfield::State::FOCUSED) + input.set_state(Textfield::State::DISABLED); + + input.set_state(Textfield::State::NORMAL); + input.change_text(""); + + for (UIElement::Type type : { UIElement::Type::SHOPSELL, UIElement::Type::SHOPSALES }) + UI::get().remove(type); + + deactivate(); + } + + void UIPersonalShop::sell(InventoryType::Id tab, int16_t slot, int32_t itemid, int16_t count) + { + // Nothing more goes in once the shop is full or open (FUN_006fd96c). + if (listings.size() >= max_items || open) + { + notice(FULL); + + return; + } + + if (auto dialog = UI::get().get_element(); dialog && dialog->is_active()) + return; + + // A set of stars or bullets counts as one. + int16_t quantity = shop_list::is_rechargeable(itemid) ? 1 : count; + + UI::get().emplace(tab, slot, itemid, quantity); + } + + void UIPersonalShop::take_back(int16_t index) const + { + if (!is_owner() || open || index < 0 || index >= static_cast(listings.size())) + return; + + // The row leaves when the server sends the new list. + if (listings[index].bundles >= 1) + Stage::get().get_player().request(ShopTakeBackPacket(index), ExclRequest::WAIT); + } + + const personal_shop::Sales &UIPersonalShop::get_sales() const + { + return sales; + } + + uint8_t UIPersonalShop::get_own_slot() const + { + return own_slot; + } + + void UIPersonalShop::create(int16_t slot, int32_t itemid) + { + if (auto shop = UI::get().get_element(); shop && shop->is_active()) + { + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline("Not available", UIChatBar::LineType::SKILL); + + return; + } + + if (!Stage::get().get_mapinfo().allows_personal_shop()) + { + notice("The personal store can only be built\\nat the free market available at main towns."); + + return; + } + + UI::get().emplace( + "Please enter the name of the store.\\n\\nIf you move before you open the store,\\n the store may not be opened.", + [slot, itemid](const std::string &title) + { + if (title.empty()) + return; + + if (CurseData::filter(title) != title) + { + notice("Cursing is not allowed."); + + return; + } + + ShopCreatePacket(title, slot, itemid).dispatch(); + }, + 50 + ); + } + + Button::State UIPersonalShop::button_pressed(uint16_t buttonid) + { + clear_tooltip(); + + switch (buttonid) + { + case Buttons::START: + // Opening an empty shop does nothing (FUN_006fcc19). + if (!listings.empty() && !open) + { + ShopBlacklistPacket({}).dispatch(); + ShopOpenPacket().dispatch(); + + open = true; + buttons[Buttons::START]->set_state(Button::State::DISABLED); + + return Button::State::DISABLED; + } + + break; + case Buttons::BUY: + buy(selected); + break; + case Buttons::CLOSE: + case Buttons::EXIT: + exit(); + + return Button::State::NORMAL; + case Buttons::INFO: + show_owner_info(); + break; + case Buttons::ITEM: + if (auto dialog = UI::get().get_element(); !dialog || !dialog->is_active()) + UI::get().emplace(); + + break; + case Buttons::ENTER: + { + std::string text = input.get_text(); + input.change_text(""); + submit(text); + break; + } + case Buttons::BAN1: + case Buttons::BAN2: + case Buttons::BAN3: + ban(static_cast(buttonid - Buttons::BAN1 + 1)); + break; + default: + // Like the status bar's, the claim button does nothing. + break; + } + + return Button::State::NORMAL; + } + + bool UIPersonalShop::is_owner() const + { + return own_slot == OWNER; + } + + int16_t UIPersonalShop::row_at(Point cursor) const + { + if (cursor.x() < ROW_LEFT || cursor.x() >= ROW_LEFT + ROW_SIZE.x() || cursor.y() < ROW_TOP) + return -1; + + int16_t k = (cursor.y() - ROW_TOP) / ROW_PITCH; + + if (k >= ROWS || (cursor.y() - ROW_TOP) % ROW_PITCH >= ROW_SIZE.y()) + return -1; + + int16_t index = first_row + k; + + return index < static_cast(listings.size()) ? index : -1; + } + + void UIPersonalShop::buy(int16_t index) + { + // Only visitors buy, from a row that exists (FUN_006fd261). + if (is_owner() || index < 0 || index >= static_cast(listings.size())) + return; + + const mini_room::Listing &listing = listings[index]; + auto row = static_cast(index); + + if (listing.bundles <= 0) + { + notice("The item has been sold already."); + + return; + } + + // v83 doesn't check our mesos or space; the server answers that. + auto send = [row](int16_t bundles, int64_t total) + { + Player &player = Stage::get().get_player(); + + if (personal_shop::over_low_level_limit(player.get_stats().get_stat(MapleStat::Id::LEVEL), total)) + { + notice(personal_shop::buy_result_text(0xe)); + + return; + } + + player.request(ShopBuyPacket(row, bundles), ExclRequest::WAIT); + }; + + if (personal_shop::sells_whole(listing.item.itemid)) + { + int64_t total = listing.price; + + UI::get().emplace( + "Are you sure you want to buy it?", + [send, total](bool yes) + { + if (yes) + send(1, total); + } + ); + + return; + } + + mini_room::Listing chosen = listing; + + UI::get().emplace( + "How many are you willing to buy?", + [send, chosen](int32_t units) + { + personal_shop::Purchase purchase = personal_shop::purchase(chosen, units); + + if (!purchase.problem.empty()) + { + notice(purchase.problem); + + return; + } + + UI::get().emplace( + personal_shop::total_question(purchase.total), + [send, purchase](bool yes) + { + if (yes) + send(purchase.bundles, purchase.total); + } + ); + }, + listing.bundles * listing.per_bundle, 1 + ); + } + + void UIPersonalShop::ban(uint8_t slot) + { + if (slot >= members.size() || !members[slot].present) + return; + + std::string name = members[slot].name.get_text(); + + UI::get().emplace( + "Would you like to ban " + name + "?", + [slot, name](bool yes) + { + if (yes) + ShopBanPacket(slot, name).dispatch(); + } + ); + } + + void UIPersonalShop::show_owner_info() const + { + // The owner's, not a visitor's (FUN_006fb07b). + const std::string &name = members[OWNER].name.get_text(); + + for (auto &mmo : *Stage::get().get_chars().get_chars()) + { + auto character = static_cast(mmo.second.get()); + + if (character->get_name() == name) + { + int32_t cid = character->get_oid(); + + if (Stage::get().get_player().request(CharInfoRequestPacket(cid), ExclRequest::USE_WAIT)) + UI::get().emplace(cid); + + return; + } + } + } + + void UIPersonalShop::submit(const std::string &text) + { + // The server adds our name and sends the line back. + if (!text.empty()) + MiniRoomChatPacket(text).dispatch(); + } + + void UIPersonalShop::add_line(const std::string &line, Color::Name color) + { + bool at_bottom = log_row + LOG_ROWS >= log_rows; + + Text text = Text(Text::Font::A12M, Text::Alignment::LEFT, color, line, LOG_WIDTH, true, 2); + auto rows = static_cast(std::max(1, (text.height() + LOG_ROW - 3) / LOG_ROW)); + + log.push_back({ text, rows }); + log_rows += rows; + + if (log.size() > LOG_LIMIT) + { + log_rows -= log.front().rows; + log_row = std::max(0, log_row - log.front().rows); + log.erase(log.begin()); + } + + if (at_bottom) + scroll_to_bottom(); + else + chat.setrows(log_row, LOG_ROWS, log_rows); + } + + void UIPersonalShop::scroll_to_bottom() + { + log_row = std::max(0, log_rows - LOG_ROWS); + chat.setrows(log_row, LOG_ROWS, log_rows); + } + + void UIPersonalShop::refresh_rows() + { + rows.clear(); + + for (const mini_room::Listing &listing : listings) + { + Row row; + row.name = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, ItemData::get(listing.item.itemid).get_name()); + string_format::format_with_ellipsis(row.name, NAME_WIDTH); + + row.price = Text(Text::Font::A12M, Text::Alignment::LEFT, PRICE_COLORS[personal_shop::price_tier(listing.price)], + personal_shop::price_text(listing)); + + rows.push_back(row); + } + + auto count = static_cast(listings.size()); + + first_row = std::clamp(first_row, 0, std::max(0, count - ROWS)); + items.setrows(first_row, ROWS, count); + } + + void UIPersonalShop::clear_tooltip() + { + UI::get().clear_tooltip(Tooltip::Parent::PERSONALSHOP); + } + + void UIPersonalShop::exit() + { + // No confirmation either way (FUN_006fb07b). + MiniRoomLeavePacket().dispatch(); + + close(); + } + + UIPersonalShop::RowIcon::RowIcon(int16_t index, int32_t itemid) : index(index), itemid(itemid) + {} + + InventoryType::Id UIPersonalShop::RowIcon::get_return_tab() const + { + return InventoryType::by_item_id(itemid); + } + + void UIPersonalShop::RowIcon::drop_on_inventory() const + { + if (auto shop = UI::get().get_element()) + shop->take_back(index); + } + + namespace + { + // The sell dialog (FUN_006fe414). + const Rectangle COUNT_BOX = Rectangle(118, 225, 32, 47); + const Rectangle PRICE_BOX = Rectangle(118, 225, 52, 67); + const Rectangle PER_BUNDLE_BOX = Rectangle(118, 225, 72, 87); + constexpr Point CHECKBOX = Point(11, 92); + constexpr Point CHECKBOX_SIZE = Point(90, 14); + constexpr Point BAR = Point(6, 51); + constexpr Point BT_OK = Point(131, 120); + constexpr Point BT_CANCEL = Point(180, 120); + } + + UIShopSell::UIShopSell(InventoryType::Id tab, int16_t slot, int32_t itemid, int16_t quantity) : UIElement(), + tab(tab), slot(slot), itemid(itemid), quantity(quantity), whole(personal_shop::sells_whole(itemid)), bundled(false) + { + nl::node PersonalShop = NxFiles::ui_classic["UIWindow.img"]["PersonalShop"]; + nl::node Basic = NxFiles::ui_classic["Basic.img"]; + + backgrnd = PersonalShop["backgrnd2"]; + bar = PersonalShop["bar"]; + checkboxes[0] = Basic["CheckBox"]["0"]; + checkboxes[1] = Basic["CheckBox"]["1"]; + + buttons[Buttons::OK] = std::make_unique(Basic["BtOK2"], BT_OK); + buttons[Buttons::CANCEL] = std::make_unique(Basic["BtCancel2"], BT_CANCEL); + + count = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, COUNT_BOX, 4); + price = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, PRICE_BOX, 10); + per_bundle = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, PER_BUNDLE_BOX, 4); + + // A whole item sells one at a time; a stack offers all of it. + count.change_text(std::to_string(whole ? 1 : quantity)); + price.change_text("0"); + per_bundle.change_text(std::to_string(whole ? quantity : 1)); + + for (Textfield *field : { &count, &price, &per_bundle }) + { + field->set_enter_callback([&](std::string) { confirm(); }); + field->set_key_callback(KeyAction::Id::ESCAPE, [&]() { cancel(); }); + } + + if (whole) + count.set_state(Textfield::State::DISABLED); + + per_bundle.set_state(Textfield::State::DISABLED); + (whole ? price : count).set_state(Textfield::State::FOCUSED); + + dimension = backgrnd.get_dimensions(); + position = centred(dimension); + } + + void UIShopSell::draw(float inter) const + { + backgrnd.draw(position); + + // Checked, the price is per bundle. + if (bundled) + bar.draw(position + BAR); + + checkboxes[bundled].draw(DrawArgument(position + CHECKBOX + Point(0, 1), whole ? 0.5f : 1.0f)); + + count.draw(position + TEXT_Y); + price.draw(position + TEXT_Y); + per_bundle.draw(position + TEXT_Y); + + UIElement::draw(inter); + } + + void UIShopSell::update() + { + UIElement::update(); + + count.update(position); + price.update(position); + per_bundle.update(position); + } + + Cursor::State UIShopSell::send_cursor(bool clicked, Point cursorpos) + { + for (Textfield *field : { &count, &price, &per_bundle }) + { + Cursor::State fstate = field->send_cursor(cursorpos, clicked); + + if (fstate != Cursor::State::IDLE) + return fstate; + } + + Rectangle checkbox = Rectangle(position + CHECKBOX, position + CHECKBOX + CHECKBOX_SIZE); + + if (!whole && checkbox.contains(cursorpos)) + { + if (clicked) + { + bundled = !bundled; + per_bundle.set_state(bundled ? Textfield::State::NORMAL : Textfield::State::DISABLED); + + return Cursor::State::IDLE; + } + + return Cursor::State::CANCLICK; + } + + return UIElement::send_cursor(clicked, cursorpos); + } + + void UIShopSell::send_key(int32_t, bool pressed, bool escape) + { + if (pressed && escape) + cancel(); + } + + UIElement::Type UIShopSell::get_type() const + { + return TYPE; + } + + Button::State UIShopSell::button_pressed(uint16_t buttonid) + { + if (buttonid == Buttons::OK) + confirm(); + else + cancel(); + + return Button::State::NORMAL; + } + + void UIShopSell::confirm() + { + personal_shop::SellForm form = { count.get_text(), price.get_text(), per_bundle.get_text(), bundled }; + personal_shop::SellCheck check = personal_shop::check_sale(form, itemid, quantity); + + // A problem keeps the dialog open at the box to fix (FUN_006fe2c3). + if (check.box != personal_shop::Box::NONE) + { + box(check.box).set_state(Textfield::State::FOCUSED); + notice(check.problem); + + return; + } + + cancel(); + + const personal_shop::Sale &sale = check.sale; + + if (personal_shop::too_pricey(sale.bundles, sale.price)) + { + notice(TOO_PRICEY); + + return; + } + + Stage::get().get_player().request( + ShopPutItemPacket(static_cast(tab), slot, sale.bundles, sale.per_bundle, sale.price), + ExclRequest::WAIT + ); + } + + void UIShopSell::cancel() + { + for (Textfield *field : { &count, &price, &per_bundle }) + { + if (field->get_state() == Textfield::State::FOCUSED) + field->set_state(Textfield::State::NORMAL); + } + + deactivate(); + } + + Textfield &UIShopSell::box(personal_shop::Box which) + { + switch (which) + { + case personal_shop::Box::COUNT: + return count; + case personal_shop::Box::PER_BUNDLE: + return per_bundle; + default: + return price; + } + } + + namespace + { + // The sold-items dialog (FUN_006fee6d, FUN_006ff133): four rows of + // 58, the last one the totals. + constexpr Point SALES_CLOSE = Point(213, 6); + constexpr int16_t SALES_SLIDER_X = 212; + const Range SALES_RANGE = Range(31, 260); + constexpr int16_t SALES_ROWS = 4; + constexpr int16_t SALES_PITCH = 58; + constexpr Point SALES_ICON = Point(8, 75); + constexpr Point SALES_NAME = Point(50, 32); + constexpr Point SALES_SECOND = Point(50, 51); + constexpr Point SALES_THIRD = Point(50, 70); + } + + UIShopSales::UIShopSales() : UIElement(), first_row(0) + { + nl::node Basic = NxFiles::ui_classic["Basic.img"]; + + backgrnd = NxFiles::ui_classic["UIWindow.img"]["PersonalShop"]["backgrnd3"]; + meso_icon = nl::nx::item["Special"]["0900.img"]["09000001"]["iconRaw"]; + + buttons[Buttons::CLOSE] = std::make_unique(Basic["BtClose"], SALES_CLOSE); + + totals = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, "Total Sales / Total Received"); + total = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK); + received = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK); + + slider = Slider( + Slider::Type::CLASSIC, SALES_RANGE, SALES_SLIDER_X, SALES_ROWS, 0, + [&](bool upwards) + { + int16_t shift = upwards ? -1 : 1; + + if (first_row + shift >= 0 && first_row + shift + SALES_ROWS <= total_rows()) + first_row += shift; + } + ); + + refresh(); + + dimension = backgrnd.get_dimensions(); + position = centred(dimension); + } + + void UIShopSales::draw(float inter) const + { + backgrnd.draw(position); + + for (int16_t k = 0; k < SALES_ROWS; k++) + { + auto index = static_cast(first_row + k); + Point row = position + Point(0, SALES_PITCH * k); + + if (index < records.size()) + { + const Record &record = records[index]; + + ItemData::get(record.itemid).get_icon(false).draw(row + SALES_ICON); + record.name.draw(row + SALES_NAME + TEXT_Y); + record.buyer.draw(row + SALES_SECOND + TEXT_Y); + record.units.draw(row + SALES_THIRD + TEXT_Y); + } else if (index == records.size()) + { + meso_icon.draw(row + SALES_ICON); + totals.draw(row + SALES_NAME + TEXT_Y); + total.draw(row + SALES_SECOND + TEXT_Y); + received.draw(row + SALES_THIRD + TEXT_Y); + } + } + + slider.draw(position); + + UIElement::draw(inter); + } + + void UIShopSales::remove_cursor() + { + UIElement::remove_cursor(); + + slider.remove_cursor(); + } + + Cursor::State UIShopSales::send_cursor(bool clicked, Point cursorpos) + { + if (Cursor::State sstate = slider.send_cursor(cursorpos - position, clicked)) + return sstate; + + return UIElement::send_cursor(clicked, cursorpos); + } + + void UIShopSales::send_scroll(double yoffset) + { + slider.send_scroll(yoffset); + } + + void UIShopSales::send_key(int32_t, bool pressed, bool escape) + { + if (pressed && escape) + deactivate(); + } + + UIElement::Type UIShopSales::get_type() const + { + return TYPE; + } + + void UIShopSales::refresh() + { + auto shop = UI::get().get_element(); + + if (!shop) + return; + + const personal_shop::Sales &sales = shop->get_sales(); + + records.clear(); + + for (const personal_shop::Sales::Sold &sold : sales.sold) + { + Record record; + record.itemid = sold.itemid; + record.name = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, ItemData::get(sold.itemid).get_name()); + string_format::format_with_ellipsis(record.name, NAME_WIDTH); + record.buyer = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, "Buyer : " + sold.buyer); + record.units = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, + "Number purchased : " + std::to_string(sold.units)); + + records.push_back(record); + } + + total.change_text(mini_room::meso_text(sales.total) + " mesos."); + received.change_text(mini_room::meso_text(sales.received) + " mesos."); + + first_row = std::clamp(first_row, 0, static_cast(total_rows() - SALES_ROWS)); + slider.setrows(first_row, SALES_ROWS, total_rows()); + } + + Button::State UIShopSales::button_pressed(uint16_t) + { + deactivate(); + + return Button::State::NORMAL; + } + + int16_t UIShopSales::total_rows() const + { + // The totals row and one blank after it once the list scrolls + // (FUN_006ffd6e). + auto count = static_cast(records.size()); + + return count < SALES_ROWS ? SALES_ROWS : count + 2; + } +} diff --git a/HeavenClient/IO/UITypes/UIPersonalShop.h b/HeavenClient/IO/UITypes/UIPersonalShop.h new file mode 100644 index 000000000..2ce880e4b --- /dev/null +++ b/HeavenClient/IO/UITypes/UIPersonalShop.h @@ -0,0 +1,312 @@ +#pragma once + +#include "../UIDragElement.h" + +#include "../Components/Icon.h" +#include "../Components/Slider.h" +#include "../Components/Textfield.h" + +#include "../../Character/Look/CharLook.h" +#include "../../Graphics/Text.h" +#include "../../Graphics/Texture.h" +#include "../../Net/Handlers/PersonalShop.h" + +#include +#include + +namespace ms +{ + // A personal shop (v83 CPersonalShopDlg, FUN_006fa0f5): the owner and up + // to three visitors along the top, the items for sale on the left and a + // chat on the right. The owner fills it before opening; visitors buy. + class UIPersonalShop : public UIDragElement + { + public: + static constexpr Type TYPE = UIElement::Type::PERSONALSHOP; + // Not focused, so the owner can drag items in from the inventory. + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + UIPersonalShop(const mini_room::Enter &enter); + + void draw(float inter) const override; + + void update() override; + + void remove_cursor() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + + bool send_icon(const Icon &icon, Point cursorpos) override; + + void doubleclick(Point cursorpos) override; + + void send_scroll(double yoffset) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + // The empty lower right corner lets clicks through (FUN_006fc3a8). + bool is_in_range(Point cursorpos) const override; + + UIElement::Type get_type() const override; + + // Someone came in to look. + void visit(const mini_room::Member &member); + + // The visitor in `slot` left. + void seat_left(uint8_t slot); + + // A member's equipment changed. + void change_look(uint8_t slot, const LookEntry &look); + + // A line the member in `slot` said, "name : text", in the colour of + // their name tag; others' words are filtered. + void add_chat(uint8_t slot, const std::string &line); + + // The items for sale, after any change (mode 0x19). + void set_listings(const std::vector &listings); + + // Someone bought from my shop (mode 0x1a). + void sold(const personal_shop::SoldNotice ¬ice); + + // Why my purchase failed (mode 0x18). + void buy_result(int8_t result); + + // The shop closed on us; closes the window, saying why. + void leave(int8_t reason); + + // Closes the window without telling the server. + void close(); + + // Asks how many of an inventory item to sell and for how much. + void sell(InventoryType::Id tab, int16_t slot, int32_t itemid, int16_t count); + + // Takes row `index` back into the inventory before the shop opens. + void take_back(int16_t index) const; + + const personal_shop::Sales &get_sales() const; + + uint8_t get_own_slot() const; + + // Sets up a shop with the permit in cash slot `slot` + // (FUN_00a1dd99): one room at a time, in the free market. + static void create(int16_t slot, int32_t itemid); + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + bool is_owner() const; + + int16_t row_at(Point cursor) const; + + void buy(int16_t index); + + void ban(uint8_t slot); + + void show_owner_info() const; + + void submit(const std::string &text); + + void add_line(const std::string &line, Color::Name color); + + void scroll_to_bottom(); + + void refresh_rows(); + + void clear_tooltip(); + + void exit(); + + // A row of my shop dragged back to the inventory (FUN_006fb368). + class RowIcon : public Icon::Type + { + public: + RowIcon(int16_t index, int32_t itemid); + + void drop_on_stage() const override {} + void drop_on_equips(EquipSlot::Id) const override {} + bool drop_on_items(InventoryType::Id, EquipSlot::Id, int16_t, bool) const override { return true; } + void drop_on_bindings(Point, bool) const override {} + InventoryType::Id get_return_tab() const override; + void drop_on_inventory() const override; + void set_count(int16_t) override {} + Icon::IconType get_type() override { return Icon::IconType::ITEM; } + + private: + int16_t index; + int32_t itemid; + }; + + enum Buttons : uint16_t + { + START, + BUY, + CLOSE, + EXIT, + INFO, + ITEM, + CLAIM, + ENTER, + BAN1, + BAN2, + BAN3 + }; + + struct Member + { + bool present = false; + CharLook look; + Text name; + }; + + struct Row + { + Text name; + Text price; + }; + + Texture backgrnd; + Texture select; + + std::array members; + uint8_t own_slot; + // Rows the shop holds (16). + uint8_t max_items; + // Visitors only ever see an open shop. + bool open; + + Text title; + Text meso; + + std::vector listings; + std::vector rows; + int16_t selected; + int16_t first_row; + Slider items; + Icon dragged_row; + // Where the cursor last was, to send the wheel to the right list. + Point last_cursor; + + personal_shop::Sales sales; + + struct Line + { + Text text; + int16_t rows; + }; + + std::vector log; + int16_t log_rows; + int16_t log_row; + Slider chat; + Textfield input; + }; + + // The owner's dialog for putting an item up (v83 FUN_006fe151): how many, + // at what price, and optionally in bundles. + class UIShopSell : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::SHOPSELL; + static constexpr bool FOCUSED = true; + static constexpr bool TOGGLED = false; + + UIShopSell(InventoryType::Id tab, int16_t slot, int32_t itemid, int16_t quantity); + + void draw(float inter) const override; + + void update() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + void confirm(); + + void cancel(); + + Textfield &box(personal_shop::Box box); + + enum Buttons : uint16_t + { + OK, + CANCEL + }; + + Texture backgrnd; + Texture bar; + std::array checkboxes; + + InventoryType::Id tab; + int16_t slot; + int32_t itemid; + int16_t quantity; + bool whole; + bool bundled; + + Textfield count; + Textfield price; + Textfield per_bundle; + }; + + // What the owner sold since opening, with the totals (v83 FUN_006fed4a). + class UIShopSales : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::SHOPSALES; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + UIShopSales(); + + void draw(float inter) const override; + + void remove_cursor() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + + void send_scroll(double yoffset) override; + + void send_key(int32_t keycode, bool pressed, bool escape) override; + + UIElement::Type get_type() const override; + + // A sale came in. + void refresh(); + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + int16_t total_rows() const; + + enum Buttons : uint16_t + { + CLOSE + }; + + struct Record + { + int32_t itemid; + Text name; + Text buyer; + Text units; + }; + + Texture backgrnd; + Texture meso_icon; + std::vector records; + Text totals; + Text total; + Text received; + int16_t first_row; + Slider slider; + }; +} diff --git a/HeavenClient/IO/UITypes/UITradingRoom.cpp b/HeavenClient/IO/UITypes/UITradingRoom.cpp index 9b25ece85..aae15e185 100644 --- a/HeavenClient/IO/UITypes/UITradingRoom.cpp +++ b/HeavenClient/IO/UITypes/UITradingRoom.cpp @@ -35,6 +35,10 @@ namespace ms constexpr Point BT_ENTER = Point(520, 244); constexpr Point BT_CLAIM = Point(494, 241); + // v83 draws a line's cap top about 3 below where it is drawn, we 9, + // so all text here goes up by the difference. + constexpr Point TEXT_Y = Point(0, -6); + constexpr Point FEET[2] = { Point(227, 101), Point(66, 101) }; constexpr Point NAME[2] = { Point(229, 107), Point(64, 107) }; constexpr Point MESO[2] = { Point(281, 243), Point(119, 243) }; @@ -49,13 +53,16 @@ namespace ms constexpr int16_t COUNT_Y = 149; constexpr Point SLOT_PITCH = Point(39, 37); - // The chat log and its scroll bar. - constexpr int16_t LOG_LEFT = 295; - constexpr int16_t LOG_TOP = 25; - constexpr int16_t LOG_BOTTOM = 217; - constexpr int16_t LOG_WIDTH = 237; - constexpr int16_t LOG_ROW = 13; + // The chat log (FUN_0065f381, FUN_0065fab9): eleven rows of 16 inside + // its box, wrapped to the box less 14. + constexpr int16_t LOG_LEFT = 297; + constexpr int16_t LOG_TOP = 27; + constexpr int16_t LOG_ROW = 16; + constexpr int16_t LOG_ROWS = 11; + constexpr int16_t LOG_WIDTH = 223; constexpr size_t LOG_LIMIT = 200; + constexpr int16_t LOG_CLIP_TOP = 25; + constexpr int16_t LOG_CLIP_BOTTOM = 217; constexpr int16_t SLIDER_X = 542; const Range SLIDER_RANGE = Range(23, 215); @@ -77,7 +84,7 @@ namespace ms } UITradingRoom::UITradingRoom(const mini_room::Enter &enter) : UIDragElement(), - own_slot(enter.own_slot < 2 ? enter.own_slot : 0), log_height(0), log_row(0) + own_slot(enter.own_slot < 2 ? enter.own_slot : 0), log_rows(0), log_row(0) { nl::node TradingRoom = NxFiles::ui_classic["UIWindow.img"]["TradingRoom"]; nl::node Basic = NxFiles::ui_classic["Basic.img"]; @@ -90,8 +97,9 @@ namespace ms buttons[Buttons::ENTER] = std::make_unique(NxFiles::ui_classic["UIWindow.img"]["Messenger"]["BtEnter"], BT_ENTER); buttons[Buttons::CLAIM] = std::make_unique(Basic["BtClaim"], BT_CLAIM); + // The names sit on dark tags, in white (FUN_007c27a0). for (Member &member : members) - member.name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::BLACK); + member.name = Text(Text::Font::A12M, Text::Alignment::CENTER, Color::Name::WHITE); for (const mini_room::Member &member : enter.members) visit(member); @@ -102,18 +110,17 @@ namespace ms meso_at_open = Stage::get().get_player().get_inventory().get_meso(); slider = Slider( - Slider::Type::CLASSIC, SLIDER_RANGE, SLIDER_X, log_rows(), 0, + Slider::Type::CLASSIC, SLIDER_RANGE, SLIDER_X, LOG_ROWS, 0, [&](bool upwards) { - int16_t total = (log_height + LOG_ROW - 1) / LOG_ROW; int16_t shift = upwards ? -1 : 1; - if (log_row + shift >= 0 && log_row + shift + log_rows() <= total) + if (log_row + shift >= 0 && log_row + shift + LOG_ROWS <= log_rows) log_row += shift; } ); - input = Textfield(Text::Font::A11M, Text::Alignment::LEFT, Color::Name::BLACK, INPUT, INPUT_LIMIT); + input = Textfield(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK, INPUT, INPUT_LIMIT); input.set_enter_callback( [&](std::string text) @@ -153,11 +160,12 @@ namespace ms // Our own avatar follows our equipment as it changes. const CharLook &look = side == Side::MINE ? Stage::get().get_player().get_look() : member.look; - look.draw(position + FEET[side], false, Stance::Id::STAND1, Expression::Id::DEFAULT); - member.name.draw(position + NAME[side]); + // The partner on the left faces right, towards me (FUN_007c1f37). + look.draw(position + FEET[side], side == Side::PARTNER, Stance::Id::STAND1, Expression::Id::DEFAULT); + member.name.draw(position + NAME[side] + TEXT_Y); } - meso_labels[side].draw(position + MESO[side]); + meso_labels[side].draw(position + MESO[side] + TEXT_Y); const mini_room::Offer &offer = offers[side]; @@ -180,19 +188,19 @@ namespace ms ColorBox(SHADE_SIZE.x(), SHADE_SIZE.y(), Color::Name::WHITE, 0.5f).draw(position + SHADE[side]); } - Range clip = Range(position.y() + LOG_TOP, position.y() + LOG_BOTTOM); + Range clip = Range(position.y() + LOG_CLIP_TOP, position.y() + LOG_CLIP_BOTTOM); int16_t y = LOG_TOP - log_row * LOG_ROW; - for (const Text &line : log) + for (const Line &line : log) { - if (y + line.height() > LOG_TOP && y < LOG_BOTTOM) - line.draw(DrawArgument(position + Point(LOG_LEFT, y)), clip); + if (y + line.rows * LOG_ROW > LOG_TOP && y < LOG_TOP + LOG_ROWS * LOG_ROW) + line.text.draw(DrawArgument(position + Point(LOG_LEFT, y) + TEXT_Y), clip); - y += line.height(); + y += line.rows * LOG_ROW; } slider.draw(position); - input.draw(position); + input.draw(position + TEXT_Y); UIElement::draw_buttons(inter); } @@ -289,7 +297,11 @@ namespace ms void UITradingRoom::add_chat(uint8_t slot, const std::string &line) { - add_line(slot == own_slot ? line : mini_room::filter_chat(line, CurseData::filter)); + // My lines are blue, my partner's black. + if (slot == own_slot) + add_line(line, Color::Name::SEATBLUE); + else + add_line(mini_room::filter_chat(line, CurseData::filter), Color::Name::BLACK); } void UITradingRoom::put_item(const mini_room::PutItem &put) @@ -465,37 +477,33 @@ namespace ms MiniRoomChatPacket(text).dispatch(); } - void UITradingRoom::add_line(const std::string &line) + void UITradingRoom::add_line(const std::string &line, Color::Name color) { - int16_t total = (log_height + LOG_ROW - 1) / LOG_ROW; - bool at_bottom = log_row + log_rows() >= total; + bool at_bottom = log_row + LOG_ROWS >= log_rows; - log.emplace_back(Text::Font::A11M, Text::Alignment::LEFT, Color::Name::BLACK, line, LOG_WIDTH); - log_height += log.back().height(); + Text text = Text(Text::Font::A12M, Text::Alignment::LEFT, color, line, LOG_WIDTH, true, 2); + auto rows = static_cast(std::max(1, (text.height() + LOG_ROW - 3) / LOG_ROW)); + + log.push_back({ text, rows }); + log_rows += rows; if (log.size() > LOG_LIMIT) { - log_height -= log.front().height(); + log_rows -= log.front().rows; + log_row = std::max(0, log_row - log.front().rows); log.erase(log.begin()); } if (at_bottom) scroll_to_bottom(); else - slider.setrows(log_row, log_rows(), (log_height + LOG_ROW - 1) / LOG_ROW); + slider.setrows(log_row, LOG_ROWS, log_rows); } void UITradingRoom::scroll_to_bottom() { - int16_t total = (log_height + LOG_ROW - 1) / LOG_ROW; - - log_row = std::max(0, total - log_rows()); - slider.setrows(log_row, log_rows(), total); - } - - int16_t UITradingRoom::log_rows() const - { - return (LOG_BOTTOM - LOG_TOP) / LOG_ROW; + log_row = std::max(0, log_rows - LOG_ROWS); + slider.setrows(log_row, LOG_ROWS, log_rows); } void UITradingRoom::show_item(Point cursor) diff --git a/HeavenClient/IO/UITypes/UITradingRoom.h b/HeavenClient/IO/UITypes/UITradingRoom.h index a6492da9e..2ed35ae22 100644 --- a/HeavenClient/IO/UITypes/UITradingRoom.h +++ b/HeavenClient/IO/UITypes/UITradingRoom.h @@ -90,12 +90,10 @@ namespace ms void submit(const std::string &text); - void add_line(const std::string &line); + void add_line(const std::string &line, Color::Name color); void scroll_to_bottom(); - int16_t log_rows() const; - void show_item(Point cursor); void clear_tooltip(); @@ -130,8 +128,14 @@ namespace ms // For the mesos received after fees, as v83 counts them. int64_t meso_at_open; - std::vector log; - int16_t log_height; + struct Line + { + Text text; + int16_t rows; + }; + + std::vector log; + int16_t log_rows; int16_t log_row; Slider slider; Textfield input; diff --git a/HeavenClient/IO/UITypes/UIUserList.cpp b/HeavenClient/IO/UITypes/UIUserList.cpp index d54143f59..bc30057eb 100644 --- a/HeavenClient/IO/UITypes/UIUserList.cpp +++ b/HeavenClient/IO/UITypes/UIUserList.cpp @@ -21,6 +21,7 @@ #include "UIEnterText.h" #include "../BuddyCommands.h" +#include "../GuildCommands.h" #include "../PartyCommands.h" #include "../UI.h" @@ -809,6 +810,24 @@ namespace ms change_tab(tab); } + break; + case Buttons::BT_GUILD_INVITE: + UI::get().emplace( + "Please type the name of the character, you want to invite.", + [](const std::string& name) + { + GuildCommands::invite(name); + }, + NAME_LIMIT + ); + break; + case Buttons::BT_GUILD_WITHDRAW: + GuildCommands::leave(); + break; + case Buttons::BT_GUILD_KICK: + if (const GuildMember* picked = picked_member()) + GuildCommands::expel(picked->name); + break; case Buttons::BT_GUILD_WHISPER: if (const GuildMember* picked = picked_online_member()) @@ -1052,12 +1071,18 @@ namespace ms // Inviting someone makes a party first if the player has none. enable(Buttons::BT_INVITE_BUDDY, buddy && buddy->online() && party_has_room); - // The client's rules for the rest (FUN_0090f17c) need guild + // The client's rules (FUN_0090f17c). Without a guild the player's + // rank is 0, which greys out all of them. The rest need guild // operations the server doesn't have yet. + int32_t rank = guild.rank(Stage::get().get_player().get_oid()); + const GuildMember* picked = picked_member(); const GuildMember* guild_member = picked_online_member(); - for (uint16_t id : { Buttons::BT_GUILD_INVITE, Buttons::BT_GUILD_WITHDRAW, Buttons::BT_GUILD_KICK, - Buttons::BT_GUILD_UP, Buttons::BT_GUILD_DOWN, Buttons::BT_GUILD_WHERE, + enable(Buttons::BT_GUILD_INVITE, rank == 1 || rank == 2); + enable(Buttons::BT_GUILD_WITHDRAW, rank > 1); + enable(Buttons::BT_GUILD_KICK, Guild::may_kick(rank, picked ? picked->rank : 0)); + + for (uint16_t id : { Buttons::BT_GUILD_UP, Buttons::BT_GUILD_DOWN, Buttons::BT_GUILD_WHERE, Buttons::BT_GUILD_INFO, Buttons::BT_GUILD_NOTICE, Buttons::BT_GUILD_BBS }) enable(id, false); @@ -1205,7 +1230,7 @@ namespace ms place_section_buttons(); } - const GuildMember* UIUserList::picked_online_member() const + const GuildMember* UIUserList::picked_member() const { if (guild_picked == 0 || guild_picked == Stage::get().get_player().get_oid()) return nullptr; @@ -1213,18 +1238,17 @@ namespace ms // A row counts only while the list shows it: not on another page or // in a folded section. for (const GuildSection* section : { &guild_online, &guild_offline }) - { for (const GuildRow& row : section->rows) - { if (row.id == guild_picked) - { - const GuildMember* member = guild.find(row.id); - - return member && member->online ? member : nullptr; - } - } - } + return guild.find(row.id); return nullptr; } + + const GuildMember* UIUserList::picked_online_member() const + { + const GuildMember* member = picked_member(); + + return member && member->online ? member : nullptr; + } } diff --git a/HeavenClient/IO/UITypes/UIUserList.h b/HeavenClient/IO/UITypes/UIUserList.h index 58339c4d7..8c889b4bd 100644 --- a/HeavenClient/IO/UITypes/UIUserList.h +++ b/HeavenClient/IO/UITypes/UIUserList.h @@ -208,7 +208,9 @@ namespace ms // out of view. void place_section_buttons(); void scroll_guild(int16_t steps); - // The member picked on the page shown, if online and not the player. + // The member picked on the page shown, but never the player. + const GuildMember* picked_member() const; + // The same, if online. const GuildMember* picked_online_member() const; uint16_t tab; diff --git a/HeavenClient/Net/Handlers/GuildHandlers.cpp b/HeavenClient/Net/Handlers/GuildHandlers.cpp index 3ed96d125..34fe91d85 100644 --- a/HeavenClient/Net/Handlers/GuildHandlers.cpp +++ b/HeavenClient/Net/Handlers/GuildHandlers.cpp @@ -8,6 +8,7 @@ #include "../../IO/UI.h" #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UIEnterText.h" +#include "../../IO/UITypes/UIInviteCard.h" #include "../../IO/UITypes/UINpcTalk.h" #include "../../IO/UITypes/UIStatsInfo.h" @@ -20,6 +21,7 @@ namespace ms enum Operation : uint8_t { NAME_PROMPT = 0x01, + INVITE = 0x05, INFO = 0x1A, // 0xcce NAME_TAKEN = 0x1C, @@ -29,6 +31,28 @@ namespace ms ALREADY_IN_GUILD = 0x21, // 0x15d LEVEL_TOO_LOW = 0x23, + // 0x15a for the player, 0x15b for the others + JOINED = 0x27, + // 0x15c + ALREADY_JOINED = 0x28, + // 0x160 + GUILD_FULL = 0x29, + // 0x16a + NOT_IN_CHANNEL = 0x2A, + // 0x152 for the player, 0x155 for the others + LEFT = 0x2C, + // 0x15e + LEAVE_NOT_IN_GUILD = 0x2D, + // 0x15e0 for the player, 0x153 for the others + EXPELLED = 0x2F, + // 0x15e + EXPEL_NOT_IN_GUILD = 0x30, + // 0x14e + NOT_ACCEPTING = 0x35, + // 0xaa3 + ANOTHER_INVITATION = 0x36, + // 0x14f + DECLINED = 0x37, // 0x167 ADMIN = 0x38 }; @@ -100,6 +124,50 @@ namespace ms if (auto stats = UI::get().get_element()) stats->update_all_stats(); } + + // An invitation (FUN_00a37490): a card, unless one is up already or + // the player is busy with a window, when the client turns it down + // itself. The card's X declines; letting it fade says nothing. + void invited(int32_t guild_id, const std::string& inviter) + { + std::string self = Stage::get().get_player().get_stats().get_name(); + auto cards = UI::get().get_element(); + + if (cards && cards->showing(UIInviteCard::GUILD)) + return DenyGuildRequestPacket(DenyGuildRequestPacket::ANOTHER, inviter, self).dispatch(); + + if (!cards || UI::get().modal_open()) + return DenyGuildRequestPacket(DenyGuildRequestPacket::BUSY, inviter, self).dispatch(); + + cards->add(UIInviteCard::GUILD, inviter, [guild_id, inviter, self](bool yes) + { + if (yes) + JoinGuildPacket(guild_id, Stage::get().get_player().get_oid()).dispatch(); + else + DenyGuildRequestPacket(DenyGuildRequestPacket::DECLINED, inviter, self).dispatch(); + }); + } + + // A member left (LEFT) or was expelled; the player, if it was them. + void departed(Guild& guild, int32_t cid, const std::string& name, bool expelled) + { + if (cid == Stage::get().get_player().get_oid()) + { + guild.clear(); + chat_line(expelled ? "You have been expelled from the guild." : "You have quit the guild."); + } + else + { + guild.remove(cid); + + if (name.empty()) + chat_line("Deleted Chracters have been expelled."); + else + chat_line("'" + name + (expelled ? "' has been expelled from the guild." : "' quitted the guild.")); + } + + refresh(); + } } void GuildOperationHandler::handle(InPacket& recv) const @@ -113,6 +181,12 @@ namespace ms case Operation::NAME_PROMPT: prompt_name(); break; + case Operation::INVITE: + { + int32_t guild_id = recv.read_int(); + invited(guild_id, recv.read_string()); + break; + } case Operation::INFO: guild.clear(); @@ -145,6 +219,63 @@ namespace ms case Operation::LEVEL_TOO_LOW: chat_line("You cannot make a guild, due to the limitation of minimum level requirement."); break; + case Operation::JOINED: + { + int32_t guild_id = recv.read_int(); + int32_t cid = recv.read_int(); + + if (cid == player.get_oid()) + { + // The server sends the guild when asked. + chat_line("You have entered the guild."); + ReloadGuildPacket().dispatch(); + break; + } + + if (!guild.exists() || guild_id != guild.get_data().id) + break; + + GuildMember member = GuildParser::parse_member(recv); + member.id = cid; + chat_line("'" + member.name + "' has joined the guild."); + guild.add(std::move(member)); + refresh(); + break; + } + case Operation::ALREADY_JOINED: + chat_line("Already joined the guild."); + break; + case Operation::GUILD_FULL: + chat_line("The guild, you are trying to join, has already reached the max number of users."); + break; + case Operation::NOT_IN_CHANNEL: + chat_line("The character cannot be found in the current channel."); + break; + case Operation::LEFT: + case Operation::EXPELLED: + { + int32_t guild_id = recv.read_int(); + int32_t cid = recv.read_int(); + std::string name = recv.read_string(); + + if (guild.exists() && guild_id == guild.get_data().id) + departed(guild, cid, name, mode == Operation::EXPELLED); + + break; + } + case Operation::LEAVE_NOT_IN_GUILD: + case Operation::EXPEL_NOT_IN_GUILD: + chat_line("You are not in the guild."); + break; + case Operation::NOT_ACCEPTING: + chat_line(recv.read_string() + " is currently not accepting guild invite message."); + break; + case Operation::ANOTHER_INVITATION: + chat_line("'" + recv.read_string() + "' is taking care of another invitation."); + break; + case Operation::DECLINED: + chat_line(recv.read_string() + " has denied your guild invitation."); + break; case Operation::ADMIN: chat_line("Admin cannot make a guild."); break; diff --git a/HeavenClient/Net/Handlers/Helpers/GuildParser.cpp b/HeavenClient/Net/Handlers/Helpers/GuildParser.cpp index b6eaf8714..42e38ea48 100644 --- a/HeavenClient/Net/Handlers/Helpers/GuildParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/GuildParser.cpp @@ -20,6 +20,20 @@ namespace ms return emblem; } + GuildMember parse_member(InPacket& recv) + { + GuildMember member; + member.name = recv.read_padded_string(NAME_LENGTH); + member.job = recv.read_int(); + member.level = recv.read_int(); + member.rank = recv.read_int(); + member.online = recv.read_int() != 0; + recv.skip_int(); // contribution + recv.skip_int(); // alliance rank + + return member; + } + GuildData parse_guild(InPacket& recv) { GuildData guild; @@ -37,13 +51,9 @@ namespace ms for (auto& member : guild.members) { - member.name = recv.read_padded_string(NAME_LENGTH); - member.job = recv.read_int(); - member.level = recv.read_int(); - member.rank = recv.read_int(); - member.online = recv.read_int() != 0; - recv.skip_int(); // contribution - recv.skip_int(); // alliance rank + int32_t id = member.id; + member = parse_member(recv); + member.id = id; } guild.capacity = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/Helpers/GuildParser.h b/HeavenClient/Net/Handlers/Helpers/GuildParser.h index 151b895f6..a546e8441 100644 --- a/HeavenClient/Net/Handlers/Helpers/GuildParser.h +++ b/HeavenClient/Net/Handlers/Helpers/GuildParser.h @@ -12,6 +12,9 @@ namespace ms // each as a fixed 37-byte record. GuildData parse_guild(InPacket& recv); + // One GUILDMEMBER record, which leaves out the id. + GuildMember parse_member(InPacket& recv); + // The emblem as GUILDDATA, SPAWN_PLAYER, and GUILD_MARK_CHANGED // carry it. GuildEmblem parse_emblem(InPacket& recv); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 92d2cfda6..9c314e523 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -22,6 +22,7 @@ #include "Helpers/LoginParser.h" #include "Helpers/MobStatParser.h" #include "Helpers/MovementParser.h" +#include "MiniRoom.h" #include "../../Data/SkillData.h" #include "../../Gameplay/Stage.h" @@ -84,9 +85,7 @@ namespace ms recv.read_int(); // mountexp recv.read_int(); // mounttiredness - // TODO: Shop stuff - recv.read_byte(); - // TODO: Shop stuff end + std::optional room = mini_room::read_box(recv); bool chalkboard = recv.read_bool(); std::string chalktext = chalkboard ? recv.read_string() : ""; @@ -95,10 +94,22 @@ namespace ms recv.read_byte(); // team Stage::get().get_chars().spawn( - { cid, look, level, job, name, stance, position, chair, std::move(buffs), guild, emblem } + { cid, look, level, job, name, stance, position, chair, std::move(buffs), guild, emblem, room } ); } + void UpdateCharBoxHandler::handle(InPacket& recv) const + { + int32_t cid = recv.read_int(); + std::optional room = mini_room::read_box(recv); + + // The owner sees their own balloon too. + if (Stage::get().is_player(cid)) + Stage::get().get_player().set_mini_room(room); + else if (Optional other = Stage::get().get_chars().get_char(cid)) + other->set_mini_room(room); + } + void GiveForeignBuffHandler::handle(InPacket& recv) const { int32_t cid = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 7bf5b9ae5..1e1148807 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -28,6 +28,14 @@ namespace ms void handle(InPacket& recv) const override; }; + // Put up, change or take down the balloon over someone keeping a room + // open (FUN_00938ba5) + // Opcode: UPDATE_CHAR_BOX(165) + class UpdateCharBoxHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Remove a character from the stage // Opcode: REMOVE_CHAR(161) class RemoveCharHandler : public PacketHandler diff --git a/HeavenClient/Net/Handlers/MiniRoom.h b/HeavenClient/Net/Handlers/MiniRoom.h index 0517be906..8f648d54e 100644 --- a/HeavenClient/Net/Handlers/MiniRoom.h +++ b/HeavenClient/Net/Handlers/MiniRoom.h @@ -4,6 +4,7 @@ #include "StorageContents.h" #include "../InPacket.h" +#include "../../Character/MiniRoomBox.h" #include "../Login.h" #include @@ -21,6 +22,7 @@ namespace ms { // The room types of FUN_0065de20 that HC opens. constexpr int8_t TRADING_ROOM = 3; + constexpr int8_t PERSONAL_SHOP = 4; // Each side of a trade has nine slots, numbered from 1. constexpr int8_t TRADE_SLOTS = 9; @@ -48,8 +50,37 @@ namespace ms std::string name; }; + // A row of a personal shop (FUN_007a0a3a): bundles left of + // per_bundle items each, at a price per bundle. Sold-out rows stay + // with no bundles. + struct Listing + { + int16_t bundles = 0; + int16_t per_bundle = 0; + int32_t price = 0; + storage_contents::Stored item; + }; + + inline std::vector read_listings(InPacket& recv) + { + std::vector listings; + + for (auto count = static_cast(recv.read_byte()); count > 0; count--) + { + Listing listing; + listing.bundles = recv.read_short(); + listing.per_bundle = recv.read_short(); + listing.price = recv.read_int(); + listing.item = storage_contents::read_item(recv); + listings.push_back(listing); + } + + return listings; + } + // An enter (FUN_0065dff3): the room's type, or 0 and why we can't - // come in. Rooms other than the trading room are left unread. + // come in. Rooms other than the trading room and the personal shop + // are left unread. struct Enter { int8_t type = 0; @@ -57,6 +88,10 @@ namespace ms uint8_t max_users = 0; uint8_t own_slot = 0; std::vector members; + // A personal shop's title, its room for rows, and its rows. + std::string title; + uint8_t max_items = 0; + std::vector listings; }; using LookReader = LookEntry(*)(InPacket&); @@ -70,8 +105,31 @@ namespace ms return { slot, look, name }; } + // The balloon block of UPDATE_CHAR_BOX and the player spawn + // (FUN_00938ba5): the room's type, 0 for none, then the room. + inline std::optional read_box(InPacket& recv) + { + int8_t type = recv.read_byte(); + + if (type == 0) + return std::nullopt; + + MiniRoomBox box; + box.type = type; + box.serial = recv.read_int(); + box.title = recv.read_string(); + recv.skip_byte(); // private + box.skin = static_cast(recv.read_byte()); + box.users = static_cast(recv.read_byte()); + box.max_users = static_cast(recv.read_byte()); + recv.skip_byte(); // a game in progress + + return box; + } + // The members follow the room's size and our seat, up to slot 0xFF - // (FUN_0065ec3d); the trading room reads nothing more. + // (FUN_0065ec3d); the trading room reads nothing more, and a + // personal shop its title, size and rows (FUN_007a0a3a). inline Enter read_enter(InPacket& recv, LookReader read_look) { Enter enter; @@ -81,13 +139,20 @@ namespace ms { enter.error = recv.read_byte(); } - else if (enter.type == TRADING_ROOM) + else if (enter.type == TRADING_ROOM || enter.type == PERSONAL_SHOP) { enter.max_users = static_cast(recv.read_byte()); enter.own_slot = static_cast(recv.read_byte()); for (auto slot = static_cast(recv.read_byte()); slot != 0xFF; slot = static_cast(recv.read_byte())) enter.members.push_back(read_member(recv, slot, read_look)); + + if (enter.type == PERSONAL_SHOP) + { + enter.title = recv.read_string(); + enter.max_items = static_cast(recv.read_byte()); + enter.listings = read_listings(recv); + } } return enter; @@ -190,7 +255,7 @@ namespace ms // A number with commas between the thousands, as v83 shows the // offers (FUN_00988690). - inline std::string meso_text(int32_t meso) + inline std::string meso_text(int64_t meso) { std::string text = std::to_string(meso); size_t digits = meso < 0 ? 1 : 0; diff --git a/HeavenClient/Net/Handlers/PersonalShop.h b/HeavenClient/Net/Handlers/PersonalShop.h new file mode 100644 index 000000000..c59a54eab --- /dev/null +++ b/HeavenClient/Net/Handlers/PersonalShop.h @@ -0,0 +1,305 @@ +#pragma once + +#include "MiniRoom.h" + +#include "../../IO/UITypes/EnterNumber.h" + +#include +#include +#include +#include +#include +#include + +namespace ms +{ + // The parts of v83's personal shop (mini room type 4, the dialog of + // FUN_006fa0f5) that need no UI. + namespace personal_shop + { + // The owner sits in slot 0, up to three visitors after. + constexpr uint8_t OWNER = 0; + constexpr uint8_t SEATS = 4; + + // Level 15 and below may spend a million mesos a purchase (FUN_006fd261). + constexpr int32_t LOW_LEVEL = 15; + constexpr int64_t LOW_LEVEL_MESO = 1000000; + + const std::string BUNDLE_ONLY = "The number of items should be based\\non the number of items in a bundle."; + + // Shop permits, the 514 items, open a shop when double-clicked. + inline bool is_permit(int32_t itemid) + { + return itemid / 10000 == 514; + } + + // Use, setup and etc items show how many are left and sell "N for" a + // price; equipment, throwing stars and bullets don't (FUN_006fb99d). + inline bool shows_count(int32_t itemid) + { + int32_t prefix = itemid / 1000000; + + return prefix >= 2 && prefix <= 4 && !shop_list::is_rechargeable(itemid); + } + + // A row's price: "10 for 1,500 mesos." or "1,500 mesos.". + inline std::string price_text(const mini_room::Listing &listing) + { + std::string price = mini_room::meso_text(listing.price) + " mesos."; + + if (shows_count(listing.item.itemid)) + return std::to_string(listing.per_bundle) + " for " + price; + + return price; + } + + // Prices are coloured by how many digits they have: below 100 + // thousand, a million, 10 million, 100 million, a billion, or more. + inline size_t price_tier(int32_t price) + { + constexpr int32_t bounds[] = { 100000, 1000000, 10000000, 100000000, 1000000000 }; + size_t tier = 0; + + while (tier < std::size(bounds) && price >= bounds[tier]) + tier++; + + return tier; + } + + // Why the shop closed on us (FUN_006fc731), or nothing. + inline std::string leave_text(int8_t reason) + { + switch (reason) + { + case 1: + return "You can't establish a miniroom right here."; + case 3: + return "The shop is closed."; + case 5: + return "The user has been banned."; + case 6: + return "The store cannot be opened as the time limit has passed."; + case 0xe: + return "The items are out of stock."; + case 0xf: + return "You have been banned from the room for exceeding the time limit. You are now blocked from re-entering this store."; + default: + return ""; + } + } + + // Why a purchase failed (FUN_006fcd0e), or nothing. + inline std::string buy_result_text(int8_t result) + { + switch (result) + { + case 0: + return ""; + case 1: + return "You don't have enough in stock."; + case 2: + return "You do not have enough mesos."; + case 3: + return "The price of the item is too high for the trade."; + case 4: + return "The buyer does not possess enough money to trade anymore."; + case 5: + return "Please check if your inventory is full or not."; + case 6: + return "You cannot carry more than one for this item."; + case 7: + return "Cannot be traded due to gender inconsistencies."; + case 8: + return "This item cannot be\\npurchased by anyone under 7."; + case 9: + return "Purchase was canceled because there was an item\\nthat disappeared because it expired."; + case 10: + return "A cash item purchase was canceled\\ndue to a server check."; + case 0xb: + return "This character is unable to do it."; + case 0xc: + return "The game file has been damaged\\nso you cannot participate in an item trade. Please try again\\nafter reinstalling the game."; + case 0xe: + return "Players that are Level 15 and below \\nmay only trade 1 million mesos per day. " + "\\nYou have reached the limit today,\\nplease try again tomorrow."; + default: + return "Due to an error, the trade did not happen."; + } + } + + // The fee on a sale, which v83 takes off for "Total Received" + // (FUN_006fa066): the gross times a rate, in doubles, truncated. + inline int64_t fee(int64_t gross) + { + double rate = gross >= 100000000 ? 0.03 + : gross >= 25000000 ? 0.025 + : gross >= 10000000 ? 0.02 + : gross >= 5000000 ? 0.015 + : gross >= 1000000 ? 0.009 + : gross >= 100000 ? 0.004 + : 0.0; + + return static_cast(static_cast(gross) * rate); + } + + // Someone bought bundles of row `index` (mode 0x1a, owner only). + struct SoldNotice + { + uint8_t index; + int16_t bundles; + std::string buyer; + }; + + inline SoldNotice read_sold(InPacket &recv) + { + SoldNotice sold; + sold.index = static_cast(recv.read_byte()); + sold.bundles = recv.read_short(); + sold.buyer = recv.read_string(); + + return sold; + } + + // What the owner sold since opening, for the sold-items dialog + // (FUN_006fd066). Only the client keeps it. + struct Sales + { + struct Sold + { + int32_t itemid; + int32_t units; + std::string buyer; + }; + + std::vector sold; + int64_t total = 0; + int64_t received = 0; + + void add(const mini_room::Listing &listing, int16_t bundles, const std::string &buyer) + { + int64_t gross = static_cast(bundles) * listing.price; + + sold.push_back({ listing.item.itemid, bundles * listing.per_bundle, buyer }); + total += gross; + received += gross - fee(gross); + } + }; + + // Equipment, throwing stars and bullets sell whole, as one bundle of + // one (FUN_004f3144); a set of stars counts as one. + inline bool sells_whole(int32_t itemid) + { + return itemid / 1000000 == 1 || shop_list::is_rechargeable(itemid); + } + + // What the owner puts up: bundles of per_bundle items, at price a bundle. + struct Sale + { + int16_t bundles; + int16_t per_bundle; + int32_t price; + }; + + // Whether a sale's total runs past what a character can hold. + inline bool too_pricey(int64_t bundles, int32_t price) + { + return bundles * price > std::numeric_limits::max(); + } + + // The sell dialog's boxes (FUN_006fe2c3), checked in v83's order: the + // bundle size when selling in bundles, the count, that the count + // fills whole bundles, then the price. A problem names the box to go + // back to. + struct SellForm + { + std::string count; + std::string price; + std::string per_bundle; + bool bundled; + }; + + enum class Box + { + NONE, + COUNT, + PRICE, + PER_BUNDLE + }; + + struct SellCheck + { + Box box; + std::string problem; + Sale sale; + }; + + inline SellCheck check_sale(const SellForm &form, int32_t itemid, int16_t quantity) + { + int32_t per_bundle = 1; + + if (form.bundled) + { + enter_number::Checked checked = enter_number::check(form.per_bundle, quantity); + + if (checked.problem != enter_number::Problem::NONE) + return { Box::PER_BUNDLE, enter_number::problem_text(checked.problem, quantity, 1), {} }; + + per_bundle = checked.number; + } + + enter_number::Checked count = enter_number::check(form.count, quantity); + + if (count.problem != enter_number::Problem::NONE) + return { Box::COUNT, enter_number::problem_text(count.problem, quantity, 1), {} }; + + if (count.number % per_bundle != 0) + return { Box::COUNT, BUNDLE_ONLY, {} }; + + constexpr int32_t max_price = std::numeric_limits::max(); + enter_number::Checked price = enter_number::check(form.price, max_price); + + if (price.problem != enter_number::Problem::NONE) + return { Box::PRICE, enter_number::problem_text(price.problem, max_price, 1), {} }; + + if (sells_whole(itemid)) + return { Box::NONE, "", { 1, 1, price.number } }; + + auto bundles = static_cast(count.number / per_bundle); + + return { Box::NONE, "", { bundles, static_cast(per_bundle), price.number } }; + } + + // A visitor asking for `units` of a row (FUN_006fd261): whole bundles + // only, and no more mesos than a character can hold. + struct Purchase + { + std::string problem; + int16_t bundles; + int64_t total; + }; + + inline Purchase purchase(const mini_room::Listing &listing, int32_t units) + { + if (listing.per_bundle <= 0 || units % listing.per_bundle != 0) + return { BUNDLE_ONLY, 0, 0 }; + + auto bundles = static_cast(units / listing.per_bundle); + + if (too_pricey(bundles, listing.price)) + return { "It has exceeded the limit that system allows.", 0, 0 }; + + return { "", bundles, static_cast(bundles) * listing.price }; + } + + inline std::string total_question(int64_t total) + { + return "The total price is " + std::to_string(total) + " mesos.\\nWill you make the purchase?"; + } + + // Whether a purchase goes past the low-level limit. + inline bool over_low_level_limit(int32_t level, int64_t total) + { + return level <= LOW_LEVEL && total > LOW_LEVEL_MESO; + } + } +} diff --git a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp index 3df45de31..0cba2e867 100644 --- a/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerInteractionHandlers.cpp @@ -27,6 +27,7 @@ #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UINotice.h" #include "../../IO/UITypes/UIInviteCard.h" +#include "../../IO/UITypes/UIPersonalShop.h" #include "../../IO/UITypes/UITradingRoom.h" #include "../Packets/MiniRoomPackets.h" @@ -174,7 +175,11 @@ namespace ms PUT_ITEM = 0x0F, PUT_MESO = 0x10, CONFIRMED = 0x11, - MESO_LIMIT = 0x15 + MESO_LIMIT = 0x15, + // The personal shop's own + BUY_RESULT = 0x18, + LISTINGS = 0x19, + SOLD = 0x1A }; // The chat line of a notice by code rather than a member @@ -182,7 +187,9 @@ namespace ms int8_t mode = recv.read_byte(); auto room = UI::get().get_element(); - bool open = room && room->is_active(); + auto shop = UI::get().get_element(); + bool trading = room && room->is_active(); + bool shopping = shop && shop->is_active(); if (mode == Mode::INVITE_RESULT) { @@ -197,7 +204,7 @@ namespace ms return; } - if (!open) + if (!trading && !shopping) { switch (mode) { @@ -232,6 +239,10 @@ namespace ms { UI::get().emplace(enter); } + else if (enter.type == mini_room::PERSONAL_SHOP) + { + UI::get().emplace(enter); + } else if (enter.type == 0) { std::string text = mini_room::enter_error_text(enter.error); @@ -253,18 +264,77 @@ namespace ms return; } - switch (mode) + if (mode == Mode::INVITE) { - case Mode::INVITE: + recv.skip_byte(); + recv.skip_string(); + + // v83 drops invites while a room is open; turn them down so the + // inviter hears back. + MiniRoomDeclinePacket(recv.read_int(), MiniRoomPacket::Decline::NOT_ACCEPTING).dispatch(); + + return; + } + + if (shopping) + { + switch (mode) { - recv.skip_byte(); - recv.skip_string(); + case Mode::VISIT: + { + uint8_t slot = recv.read_byte(); - // v83 drops invites while a room is open; turn them down so the - // inviter hears back. - MiniRoomDeclinePacket(recv.read_int(), MiniRoomPacket::Decline::NOT_ACCEPTING).dispatch(); - break; + shop->visit(mini_room::read_member(recv, slot, LoginParser::parse_look)); + break; + } + case Mode::CHAT: + { + if (recv.read_byte() == CHAT_NOTICE) + break; + + uint8_t slot = recv.read_byte(); + std::string line = recv.read_string(); + + shop->add_chat(slot, line); + break; + } + case Mode::AVATAR: + { + uint8_t slot = recv.read_byte(); + + shop->change_look(slot, LoginParser::parse_look(recv)); + break; + } + case Mode::LEAVE: + { + uint8_t slot = recv.read_byte(); + + // Only our own leaving says why (FUN_006fc731). + if (slot == shop->get_own_slot()) + shop->leave(recv.read_byte()); + else + shop->seat_left(slot); + + break; + } + case Mode::BUY_RESULT: + shop->buy_result(recv.read_byte()); + break; + case Mode::LISTINGS: + shop->set_listings(mini_room::read_listings(recv)); + break; + case Mode::SOLD: + shop->sold(personal_shop::read_sold(recv)); + break; + default: + break; } + + return; + } + + switch (mode) + { case Mode::VISIT: { uint8_t slot = recv.read_byte(); diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 8775155ed..ece5d2f3d 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -121,6 +121,7 @@ namespace ms PARTY_OPERATION = 124, DENY_PARTY_REQUEST = 125, GUILD_OPERATION = 126, + DENY_GUILD_REQUEST = 127, ADMIN_COMMAND = 128, BUDDYLIST_MODIFY = 130, USE_DOOR = 133, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index 0373b68e4..d9e908771 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -110,6 +110,7 @@ namespace ms /// Messaging CHAT_RECEIVED = 162, + UPDATE_CHAR_BOX = 165, SCROLL_RESULT = 167, /// MapObject @@ -222,6 +223,7 @@ namespace ms // MapObject handlers emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 9b3db1a44..a31877148 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -378,8 +378,12 @@ namespace ms public: enum Operation : int8_t { + RELOAD = 0, CREATE = 2, - INVITE = 5 + INVITE = 5, + JOIN = 6, + LEAVE = 7, + EXPEL = 8 }; GuildOperationPacket(Operation op) : OutPacket(OutPacket::Opcode::GUILD_OPERATION) @@ -408,6 +412,65 @@ namespace ms } }; + // Asks for the player's guild again, after joining one + class ReloadGuildPacket : public GuildOperationPacket + { + public: + ReloadGuildPacket() : GuildOperationPacket(GuildOperationPacket::Operation::RELOAD) {} + }; + + // Takes up an invitation to a guild + class JoinGuildPacket : public GuildOperationPacket + { + public: + JoinGuildPacket(int32_t guild_id, int32_t cid) : GuildOperationPacket(GuildOperationPacket::Operation::JOIN) + { + write_int(guild_id); + write_int(cid); + } + }; + + // Quits the player's guild + class LeaveGuildPacket : public GuildOperationPacket + { + public: + LeaveGuildPacket(int32_t cid, const std::string& name) : + GuildOperationPacket(GuildOperationPacket::Operation::LEAVE) + { + write_int(cid); + write_string(name); + } + }; + + // Expels a member from the player's guild + class ExpelFromGuildPacket : public GuildOperationPacket + { + public: + ExpelFromGuildPacket(int32_t cid, const std::string& name) : + GuildOperationPacket(GuildOperationPacket::Operation::EXPEL) + { + write_int(cid); + write_string(name); + } + }; + + // Turns down an invitation to a guild; the inviter hears the reason + // Opcode: DENY_GUILD_REQUEST(127) + class DenyGuildRequestPacket : public OutPacket + { + public: + // Not taking invitations, already considering another, or declined. + static constexpr int8_t BUSY = 0x35, ANOTHER = 0x36, DECLINED = 0x37; + + DenyGuildRequestPacket(int8_t reason, const std::string& inviter, const std::string& self) : + OutPacket(OutPacket::Opcode::DENY_GUILD_REQUEST) + { + write_byte(reason); + write_string(inviter); + write_string(self); + } + }; + // Opcode: BUDDYLIST_MODIFY(130) class BuddyListModifyPacket : public OutPacket { diff --git a/HeavenClient/Net/Packets/MiniRoomPackets.h b/HeavenClient/Net/Packets/MiniRoomPackets.h index ead7c7f08..f560008bd 100644 --- a/HeavenClient/Net/Packets/MiniRoomPackets.h +++ b/HeavenClient/Net/Packets/MiniRoomPackets.h @@ -25,7 +25,8 @@ namespace ms { - // Mini room (trading room) requests. Every one starts with a mode byte. + // Mini room (trading room and personal shop) requests. Every one starts + // with a mode byte. // Opcode: PLAYER_INTERACTION(123) class MiniRoomPacket : public OutPacket { @@ -46,10 +47,16 @@ namespace ms VISIT = 0x04, CHAT = 0x06, LEAVE = 0x0A, + OPEN = 0x0B, PUT_ITEM = 0x0F, PUT_MESO = 0x10, TRADE = 0x11, - TRADE_REPLY = 0x14 + TRADE_REPLY = 0x14, + SHOP_PUT_ITEM = 0x16, + SHOP_BUY = 0x17, + SHOP_TAKE_BACK = 0x1B, + SHOP_BAN = 0x1C, + SHOP_BLACKLIST = 0x1E }; MiniRoomPacket(Mode mode) : OutPacket(OutPacket::Opcode::PLAYER_INTERACTION) @@ -173,4 +180,94 @@ namespace ms write_items(itemids); } }; + + // Opens a personal shop (type 4) with a permit from the cash tab (v83 + // FUN_00a1dd99). The shop stays closed to visitors until it opens. + class ShopCreatePacket : public MiniRoomPacket + { + public: + static constexpr int8_t PERSONAL_SHOP = 4; + + ShopCreatePacket(const std::string &title, int16_t permit_slot, int32_t permit_id) : MiniRoomPacket(Mode::CREATE) + { + write_byte(PERSONAL_SHOP); + write_string(title); + write_byte(0); + write_short(permit_slot); + write_int(permit_id); + } + }; + + // Lists bundles of an inventory item in my shop, at a price a bundle. + class ShopPutItemPacket : public MiniRoomPacket + { + public: + ShopPutItemPacket(int8_t tab, int16_t slot, int16_t bundles, int16_t per_bundle, int32_t price) + : MiniRoomPacket(Mode::SHOP_PUT_ITEM) + { + write_byte(tab); + write_short(slot); + write_short(bundles); + write_short(per_bundle); + write_int(price); + } + }; + + // Buys bundles of a shop's row. Like the trade, HC sends 0 for the + // item's CRC. + class ShopBuyPacket : public MiniRoomPacket + { + public: + ShopBuyPacket(uint8_t index, int16_t bundles) : MiniRoomPacket(Mode::SHOP_BUY) + { + write_byte(static_cast(index)); + write_short(bundles); + write_int(0); + } + }; + + // Takes a row of my shop back into the inventory, before it opens. + class ShopTakeBackPacket : public MiniRoomPacket + { + public: + ShopTakeBackPacket(int16_t index) : MiniRoomPacket(Mode::SHOP_TAKE_BACK) + { + write_short(index); + } + }; + + // The names my shop turns away. v83 sends its blacklist right before + // opening; HC has no blacklist yet and sends none. + class ShopBlacklistPacket : public MiniRoomPacket + { + public: + ShopBlacklistPacket(const std::vector &names) : MiniRoomPacket(Mode::SHOP_BLACKLIST) + { + write_short(static_cast(names.size())); + + for (const std::string &name : names) + write_string(name); + } + }; + + // Opens my shop to visitors. + class ShopOpenPacket : public MiniRoomPacket + { + public: + ShopOpenPacket() : MiniRoomPacket(Mode::OPEN) + { + write_byte(1); + } + }; + + // Sends the visitor in a seat away, for good. + class ShopBanPacket : public MiniRoomPacket + { + public: + ShopBanPacket(uint8_t slot, const std::string &name) : MiniRoomPacket(Mode::SHOP_BAN) + { + write_byte(static_cast(slot)); + write_string(name); + } + }; } diff --git a/HeavenClient/tests/guild.cpp b/HeavenClient/tests/guild.cpp index 8a3211c59..e5ddec096 100644 --- a/HeavenClient/tests/guild.cpp +++ b/HeavenClient/tests/guild.cpp @@ -87,6 +87,25 @@ int main() require(guild.online_ids(9) == std::vector{ 1 }, "guild chat goes to online members"); require(guild.find(2) && guild.find(2)->name == "Bobby" && !guild.find(3), "find by id"); + require(guild.rank(2) == 3 && guild.rank(9) == 0, "ranks, and none for outsiders"); + require(!guild.full(), "two of ten"); + + Writer joined; + joined.member("Carol", 5, 1); + InPacket record(joined.bytes.data(), joined.bytes.size()); + GuildMember carol = GuildParser::parse_member(record); + require(!record.available() && carol.name == "Carol" && carol.rank == 5, "one member's record"); + carol.id = 3; + guild.add(carol); + guild.add(carol); + require(guild.get_data().members.size() == 3 && guild.find(3)->online, "a member joins once"); + guild.remove(2); + require(!guild.find(2) && guild.is_master(1) && guild.get_data().members.size() == 2, "a member leaves"); + + require(Guild::may_kick(1, 2) && Guild::may_kick(1, 5) && !Guild::may_kick(1, 1), "the master expels the rest"); + require(!Guild::may_kick(2, 2) && Guild::may_kick(2, 3), "a Jr. Master expels the ranks below"); + require(!Guild::may_kick(3, 4) && !Guild::may_kick(1, 0), "members don't, and not without a pick"); + guild.clear(); require(!guild.exists() && guild.get_data().members.empty(), "clearing forgets the guild"); diff --git a/HeavenClient/tests/personal_shop.cpp b/HeavenClient/tests/personal_shop.cpp new file mode 100644 index 000000000..c3ceab4f3 --- /dev/null +++ b/HeavenClient/tests/personal_shop.cpp @@ -0,0 +1,184 @@ +#include "../Net/Handlers/PersonalShop.h" +#include +#include +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +struct Writer +{ + std::vector bytes; + + template + Writer& put(T value) + { + int8_t raw[sizeof(T)]; + std::memcpy(raw, &value, sizeof(T)); + bytes.insert(bytes.end(), raw, raw + sizeof(T)); + return *this; + } + + Writer& string(const std::string& text) + { + put(static_cast(text.size())); + bytes.insert(bytes.end(), text.begin(), text.end()); + return *this; + } + + Writer& stack(int32_t itemid, int16_t count) + { + put(2).put(itemid).put(0).put(0); + return put(count).string("").put(0); + } +}; + +// Stands in for the AvatarLook parser: a look here is just its hair. +ms::LookEntry read_hair(ms::InPacket& recv) +{ + ms::LookEntry look = {}; + look.hairid = recv.read_int(); + + return look; +} + +ms::mini_room::Listing listing(int32_t itemid, int16_t bundles, int16_t per_bundle, int32_t price) +{ + ms::mini_room::Listing row; + row.bundles = bundles; + row.per_bundle = per_bundle; + row.price = price; + row.item.itemid = itemid; + row.item.count = per_bundle; + + return row; +} + +int main() +{ + try + { + using namespace ms; + using namespace ms::personal_shop; + + // A visitor comes into Alice's shop, which sells potions in tens. + Writer shop; + shop.put(4).put(4).put(1); + shop.put(0).put(30000).string("Alice"); + shop.put(1).put(30010).string("Bob"); + shop.put(0xFF); + shop.string("Cheap pots").put(16); + shop.put(2); + shop.put(3).put(10).put(1500).stack(2000000, 10); + shop.put(0).put(1).put(25000000).stack(4000000, 1); + InPacket shop_recv(shop.bytes.data(), shop.bytes.size()); + mini_room::Enter enter = mini_room::read_enter(shop_recv, read_hair); + + require(!shop_recv.available(), "a shop's enter is read up to its end"); + require(enter.type == mini_room::PERSONAL_SHOP && enter.max_users == 4 && enter.own_slot == 1, "the shop and our seat are read"); + require(enter.members.size() == 2 && enter.members[0].name == "Alice", "the owner sits first"); + require(enter.title == "Cheap pots" && enter.max_items == 16, "the title and room for rows are read"); + require(enter.listings.size() == 2, "the rows are read"); + require(enter.listings[0].bundles == 3 && enter.listings[0].per_bundle == 10, "a row's bundles are read"); + require(enter.listings[0].price == 1500 && enter.listings[0].item.itemid == 2000000, "a row's price and item are read"); + require(enter.listings[1].bundles == 0, "a sold out row stays"); + + // The rows as the shop shows them. + require(price_text(enter.listings[0]) == "10 for 1,500 mesos.", "stacks sell so many for a price"); + require(price_text(listing(1302000, 1, 1, 70000)) == "70,000 mesos.", "equips sell for a price"); + require(price_text(listing(2070000, 1, 1, 9000)) == "9,000 mesos.", "stars sell for a price"); + require(shows_count(2000000) && shows_count(4000000) && shows_count(3010000), "use, setup and etc show a count"); + require(!shows_count(1302000) && !shows_count(2070000) && !shows_count(2330000), "equips, stars and bullets don't"); + require(price_tier(99999) == 0 && price_tier(100000) == 1 && price_tier(999999) == 1, "prices under a million"); + require(price_tier(1000000) == 2 && price_tier(10000000) == 3 && price_tier(100000000) == 4, "prices by digits"); + require(price_tier(1000000000) == 5 && price_tier(2147483647) == 5, "a billion and more"); + + // The fee comes off what the owner receives. + require(fee(99999) == 0 && fee(100000) == 400, "small sales go free"); + require(fee(1000000) == 9000 && fee(5000000) == 75000, "the fee grows with the sale"); + require(fee(10000000) == 200000 && fee(25000000) == 625000 && fee(100000000) == 3000000, "the top rates"); + + Writer sold; + sold.put(0).put(2).string("Bob"); + InPacket sold_recv(sold.bytes.data(), sold.bytes.size()); + SoldNotice notice = read_sold(sold_recv); + + require(!sold_recv.available() && notice.index == 0 && notice.bundles == 2 && notice.buyer == "Bob", "a sale is read"); + + Sales sales; + sales.add(listing(2000000, 3, 10, 1500), 2, "Bob"); + sales.add(listing(1302000, 1, 1, 1000000), 1, "Carol"); + + require(sales.sold.size() == 2 && sales.sold[0].units == 20 && sales.sold[0].buyer == "Bob", "a sale counts its items"); + require(sales.total == 1003000 && sales.received == 1003000 - 9000, "the totals keep the fee per sale"); + + // Selling: the boxes are checked bundle size first, then the count, + // then the price. + SellCheck stack = check_sale({ "100", "1500", "1", false }, 2000000, 100); + + require(stack.box == Box::NONE && stack.sale.bundles == 100 && stack.sale.per_bundle == 1, "loose items sell one a bundle"); + require(stack.sale.price == 1500, "the price is read"); + + SellCheck tens = check_sale({ "100", "1500", "10", true }, 2000000, 100); + + require(tens.box == Box::NONE && tens.sale.bundles == 10 && tens.sale.per_bundle == 10, "bundles divide the count"); + + SellCheck uneven = check_sale({ "95", "1500", "10", true }, 2000000, 100); + + require(uneven.box == Box::COUNT && uneven.problem == BUNDLE_ONLY, "the count must fill whole bundles"); + require(check_sale({ "10", "1500", "200", true }, 2000000, 100).box == Box::PER_BUNDLE, "a bundle can't be over the stack"); + require(check_sale({ "101", "1500", "1", false }, 2000000, 100).box == Box::COUNT, "the count can't be over the stack"); + require(check_sale({ "x", "1500", "1", false }, 2000000, 100).problem == "Only numbers are allowed.", "the count is a number"); + require(check_sale({ "10", "0", "1", false }, 2000000, 100).box == Box::PRICE, "the price is at least 1"); + + SellCheck sword = check_sale({ "1", "70000", "1", false }, 1302000, 1); + + require(sword.box == Box::NONE && sword.sale.bundles == 1 && sword.sale.per_bundle == 1, "an equip sells whole"); + require(sells_whole(1302000) && sells_whole(2070000) && !sells_whole(2000000), "equips and stars sell whole"); + require(too_pricey(2, 2000000000) && !too_pricey(1, 2147483647), "a sale can't total past what fits"); + + // Buying takes whole bundles. + Purchase three = purchase(listing(2000000, 3, 10, 1500), 30); + + require(three.problem.empty() && three.bundles == 3 && three.total == 4500, "units buy bundles"); + require(purchase(listing(2000000, 3, 10, 1500), 25).problem == BUNDLE_ONLY, "a part of a bundle can't be bought"); + require(!purchase(listing(2000000, 3, 1, 2000000000), 2).problem.empty(), "a purchase can't total past what fits"); + require(total_question(4500) == "The total price is 4500 mesos.\\nWill you make the purchase?", "the purchase asks"); + require(over_low_level_limit(15, 1000001) && !over_low_level_limit(15, 1000000), "low levels spend a million"); + require(!over_low_level_limit(16, 5000000), "higher levels spend freely"); + + require(leave_text(3) == "The shop is closed." && leave_text(0).empty(), "the owner closing says so"); + require(buy_result_text(0).empty() && buy_result_text(2) == "You do not have enough mesos.", "a purchase's result"); + require(buy_result_text(0x7F) == "Due to an error, the trade did not happen.", "unknown results are an error"); + require(is_permit(5140000) && is_permit(5140006) && !is_permit(5150000), "permits are 514"); + + // The balloon over the owner. + Writer box; + box.put(4).put(77).string("Cheap pots").put(0); + box.put(2).put(1).put(4).put(0); + InPacket box_recv(box.bytes.data(), box.bytes.size()); + std::optional room = mini_room::read_box(box_recv); + + require(room && !box_recv.available(), "a balloon is read up to its end"); + require(room->type == 4 && room->serial == 77 && room->title == "Cheap pots", "the room is read"); + require(room->skin == 2 && room->users == 1 && room->max_users == 4, "the skin and seats are read"); + + Writer none; + none.put(0); + InPacket none_recv(none.bytes.data(), none.bytes.size()); + + require(!mini_room::read_box(none_recv) && !none_recv.available(), "no room is one byte"); + + std::cout << "Personal shop 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 c5c84cdac..cad2171cb 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -261,13 +261,12 @@ from `UI.nx`. Moved so far: closes before a row acts. Info opens the character info window, SAVE NAME copies the name and opens the chat field, WHISPER fills in "/w name ", TRADE and PARTY INVITE do what the info window's trade and `/party invite` - do, GUILD INVITE sends GUILD_OPERATION 5 after v83's guild checks (the - server ignores it until it has guild invites), and ALERT GM shows v83's - notice that reports are down. Approximations: v83 also hits - the character's second position and a morph's own box; it puts the chat - into whisper mode rather than filling in a command; and it offers Add - Junior to family members, which HC doesn't have. The right button acts when - it comes up, everywhere, as in v83. + do, GUILD INVITE sends GUILD_OPERATION 5 after v83's guild checks, and + ALERT GM shows v83's notice that reports are down. Approximations: v83 + also hits the character's second position and a morph's own box; it puts + the chat into whisper mode rather than filling in a command; and it offers + Add Junior to family members, which HC doesn't have. The right button + acts when it comes up, everywhere, as in v83. - **Quest log** (`UIQuestLog`, `UIWindow.img/Quest`): Available, In Progress, and Complete tabs (`Basic.img/Tab2`), quests folded by area, and a detail panel with the quest's text and FIND NPC or QUEST HELPER, laid out from @@ -470,7 +469,7 @@ side yet. | Character info, minimap, world map | Implemented | | Party (`UIUserList` Party tab, `UIPartyHP`, party chat) | Implemented; see `parties.md` | | Buddy list (`UIUserList` Friend tab, buddy chat) | Implemented without groups; see `buddies.md` | -| Guild (`UIUserList` Guild tab, name tags, guild chat) | Founding and viewing only; see `guilds.md` | +| Guild (`UIUserList` Guild tab, name tags, guild chat) | Founding, invitations, leaving and expelling; see `guilds.md` | | Alliance / blacklist tabs (`UIUserList`) | Tab strip only | | Monster Book (`UIMonsterBook`) | Implemented: cards, summary, monster pages, search, cover; see `combat.md` | | Cash shop (`UICashShop`) | Enter/exit, catalog, buying, gifts, Cash Inventory, wish list, and moving items to and from the bags | @@ -651,7 +650,7 @@ the screen size. | System | Client status | Effort | | --- | --- | --- | | Buddy groups | Default group only | S | -| Guild | Window and founding only; member management, emblem and BBS UIs missing | M | +| Guild | Window, founding, invitations, leaving and expelling; ranks, emblem and BBS UIs missing | M | | Player shops | Nothing | L | | Cash shop | No coupons, charging, or storage window | S | | Quest accept/complete UI flow | Partial | S | diff --git a/agent_docs/guilds.md b/agent_docs/guilds.md index 0af20e873..1aa22202c 100644 --- a/agent_docs/guilds.md +++ b/agent_docs/guilds.md @@ -3,8 +3,9 @@ A guild is a named group of up to its capacity of characters, founded at Heracle in the Orbis guild headquarters. Members see the guild's name and emblem under each other's names, the stat window and character info show it, -and guild chat reaches the members online. So far a guild can only be -founded; nobody can join, leave, or change it in game. +and guild chat reaches the members online. The master and the Jr. Masters +invite players on their channel; members leave, and are expelled. Nothing +else about a guild changes in game yet. ## Server @@ -28,8 +29,10 @@ case (a unique index on `lower(name)`). licensed script has his text, so they stay placeholders (`unsourced` in `game-data/server_content.toml`). - `GUILD_OPERATION` (`net/src/packet/handle/world/guild.rs`) becomes a - `GuildRequest`: reload (mode 0) or create with a name (mode 2). Other - modes are ignored. + `GuildRequest`: reload (mode 0), create with a name (2), invite by name + (5), accept an invitation (6), leave (7), or expel (8). Other modes are + ignored. `DENY_GUILD_REQUEST` (127) turns an invitation down: 0x35 when + the client was busy, 0x36 when it already shows one, 0x37 when declined. - The client actor (`runtime/src/actor/client/guilds.rs`) founds the guild only while `naming_guild` is set, so a forged create does nothing. It checks everything again: a GM gets 0x38, a member 0x21, a low level 0x23, @@ -42,6 +45,25 @@ case (a unique index on `lower(name)`). guild and the world directory sends it (0x1A), marking members online on any channel (`DirectoryMessage::GuildInfo`). The client starts with no guild, so nothing is sent for none. +- Invitations: the actor checks that the inviter is the master or a Jr. + Master and that the guild has room (`Guild::may_invite`), then the world + directory finds the invitee by name, matching case, on the inviter's + channel. Nobody there gets 0x2A; someone in a guild gets 0x28. The + directory remembers the invitation for 3 minutes, one guild at a time + (`runtime/src/actor/guild_invites.rs`): another guild's invitation is + refused with 0x36 while one is waiting. A refusal reaches the inviter + once, as 0x35, 0x36 or 0x37 with the invitee's name. +- Accepting an invitation that is still waiting tells the invitee's actor + to join. `db::guild::add_member` adds them at rank 5, locking the guild so + it can't pass its capacity (0x29 when full). Every member online gets + 0x27 with the newcomer's record, the newcomer included; their client asks + for the whole guild again (mode 0). +- Leaving (anyone but the master) sends 0x2C to the members and the leaver. + Expelling follows the client's rule (`Guild::may_expel`): the master and + Jr. Masters expel, but nobody the master, and only the master a Jr. + Master. 0x2F goes to every member, the expelled one included, and their + actor, if online, reads their guild again (`DirectoryMessage::GuildChanged`). + Either way the map sees the name and emblem go. - Guild chat is the guild mode (2) of `MULTICHAT`. The server ignores the client's recipient list and sends the line to every other member online (`DirectoryMessage::Deliver`). The speaker's client prints its own line. @@ -64,6 +86,26 @@ ints. The client takes the first member for the master. master while other members get a chat line. - 0x1C has Heracle say the name is taken, then asks again. 0x21, 0x23, 0x38 and unknown modes are chat lines. + - 0x05, an invitation, shows a card above the status bar + (`UIInviteCard::GUILD`): "from ''" over "Guild invite". O sends + mode 6 and X sends 0x37; letting it fade sends nothing. As in v83, the + client refuses by itself with 0x36 while a guild card is up, or 0x35 + while a window that holds the cursor is open (`UI::modal_open`). + - 0x27 says "You have entered the guild." and asks for the guild (mode + 0), or adds someone else's record. 0x2C and 0x2F take a member out, or + clear the guild if it is the player. These three only count for the + player's guild. 0x28, 0x29, 0x2A, 0x2D, 0x30 and the refusals 0x35 to + 0x37 are chat lines. +- `IO/GuildCommands` has v83's checks before asking the server: + - `/guildinfo` lists every member, online or not, and says if the player + is the master. + - `/leaveguild` asks "Would you like to quit the guild?"; the master can't. + - `/guildinvite ` needs the master or a Jr. Master and room. + - `/guildexpel ` needs the name of a member (matching case) who + isn't the master, and only the master names a Jr. Master. It asks + first, and leaves the player's own rank to the server. + - Commands match without regard to case, as the party commands do; the + context menu's GUILD INVITE uses `invite` too. - `GUILD_NAME_CHANGED` (202) and `GUILD_MARK_CHANGED` (203) change what another character shows. - `Char` draws the guild name under the character's name, with the emblem @@ -82,14 +124,20 @@ ints. The client takes the first member for the master. its header is wholly in view. - Clicking a row picks the member. WHISPER and TALK work for a picked member online who isn't the player; PARTY INVITE also needs the player - to lead a party. The other nine buttons are always disabled for now. + to lead a party. + - INVITE (the master and Jr. Masters) asks for a name, LEAVE (anyone but + the master) and EXPEL run the commands. EXPEL needs a picked member + the player may expel (`Guild::may_kick`), online or not. The other six + buttons are always disabled for now. - The client places the guild buttons by their top left, ignoring the origin that BtGuildBBS and BtInfo have (inferred from the button grid), so the tab adds the origin back. ## Not implemented -- Invitations, joining, leaving, expelling, rank changes, and disbanding. +- Rank changes and disbanding. +- The card's option to refuse all invitations, and the blacklist, which + v83 also checks before showing one. - Editing the notice, titles, or emblem; raising the capacity. - Telling members when someone logs in or out, levels up, or changes job: the window shows who was online when the player entered the game. @@ -103,3 +151,5 @@ ints. The client takes the first member for the master. 192 px view). - How text is clipped at the list's and the notice strip's edges. - The name tag's exact placement, which no footage has confirmed. +- `UI::modal_open` stands in for v83's FUN_0049ed00; which windows count + as modal there hasn't been checked. diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index 0e4d48f24..7c296d19a 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -315,6 +315,88 @@ and the partner gets theirs back with LEAVE reason 2. If the field dies, the trade fails with reason 8. Anything that no longer fits when an offer comes back drops at the player's feet. +## Personal shops + +A player with a store permit (`514xxxx`, in the cash bag) opens a personal +shop (mini room type 4) on a Free Market map and sells items in bundles to +up to three visitors at a time. Shops use the same PLAYER_INTERACTION +packets as trading. `PlayerInteractionHandler` turns the shop modes into a +`net::personal_shop::PersonalShopRequest`. Visiting, chatting and leaving +stay `TradeRequest`s, shared with trading rooms. Maps allow shops where +Map.nx sets `info/personalShop` (the Free Market rooms). + +The work is split three ways, as for trading: + +- The room lives in the field (`runtime::actor::field_shops::Shops`). It + holds the members, title, balloon skin, whether the shop is open, the + blacklist, the banned names, and a display copy of the listings. It also + collects balloon changes, which the field shows by setting the owner's + `FieldAvatar::mini_room` and broadcasting UPDATE_CHAR_BOX (`0xA5`). The + same box goes into the owner's spawn packet. +- The stock lives in the owner's `ClientActor` (`client/personal_shop.rs`). + Listing an item takes it out of the inventory, and the actor holds it as + `net::personal_shop::Listing`s until it sells or the shop closes. `flush` + adds the unsold stock back into the saved copy, as for a trade offer. +- `net::personal_shop` holds the rules: tax, permit, list item, check + purchase, check sale, sell, unsold. + +A visit names only a room id, so the field sends it to the shops if one has +that id and to the trades otherwise. Room ids come from one counter for +both kinds. A player in one kind of room counts as busy for the other. The +visitor's actor makes no state until the room says it is in: a trading +room's `TradeEvent::Started`, or a shop's `ShopEvent::Entered`. + +The flow: + +1. **Create** (mode 0, type 4: title, a byte, permit slot, permit id). A + permit not in that slot is refused silently. Off a Free Market map the + owner gets ENTER_RESULT `{5, 0, 0xf}`. Otherwise the owner gets the empty + shop. +2. **Put item** (`0x16`: bag, slot, bundles, per bundle, price) and **take + back** (`0x1b`: index) work only before the shop opens. Stars, bullets + and equipment go whole, as one bundle. The storage rules decide what may + be sold. Each change sends the inventory operation, then the listings + (`0x19`) to everyone in the shop. Taking back an item with no room for it + gets the inventory-full notice. +3. **Open**: the client sends its blacklist (`0x1e`), then `{0xb, 1}`. + Opening needs at least one listing. The balloon shows, and visitors may + enter. Names on the blacklist (`0x1f` adds, `0x20` removes) stay out. +4. **Visit** (4: room id). A closed shop answers ENTER_RESULT 1, a full one + 2, and a banned name `0x11`. The visitor gets ENTER (5) with the members + and listings, and the others get VISIT (4). +5. **Buy** (`0x17`: index, bundles). See below. The buyer gets the items + and the new listings, or BUY_RESULT (`0x18`) with a reason: 1 out of + stock, 2 not enough mesos, 3 too expensive, 5 inventory full, 6 + one-of-a-kind, `0xe` over the low-level meso limit. The owner gets SOLD + (`0x1a`: index, bundles, buyer) and the mesos less tax. +6. **Ban** (`0x1c`: slot, name; `0x1d` when the visitor's client turns + itself out after an hour) sends that visitor LEAVE reason 5 (`0xf` for + overstaying). They can't come back while the shop is open. + +A purchase crosses two client actors, so it runs in steps that never lose +or copy an item: + +1. The buyer asks the room for the listing (`Quote`) and checks it can pay + and carry it (`check_purchase`). +2. It takes the mesos and sends the room a `Sale` with a oneshot for the + answer. The room passes it to the owner, or drops it if the buyer is no + longer a visitor. +3. The owner checks the sale against its stock (`check_sale`) and answers. + Only if the buyer is still waiting does it take the bundles off the + listing and add the mesos less tax. +4. The buyer takes the items on yes. On no, a dropped sale, or no answer + within 5 seconds, it gets its mesos back and BUY_RESULT. + +The tax is 3% from 100,000,000 mesos, 2.5% from 25,000,000, 2% from +10,000,000, 1.5% from 5,000,000, 0.9% from 1,000,000, 0.4% from 100,000, +and nothing below that. It is worked out in doubles and rounded down, as +the client does. + +When the last bundle sells, the shop closes: the visitors get LEAVE reason +`0xe` and the owner `{0xa, 0, 0xe}`. The owner leaving (`0xa`), changing +maps or channels, entering the Cash Shop, or logging out also closes it. +The owner gets the unsold stock back, and the visitors get LEAVE reason 3. + ## Tests `game` unit tests cover stacking, full bags, removal order, merging, @@ -353,3 +435,16 @@ Trading is tested at three levels: meso limit. It finds both sides saved after a relogin. - The second cancels a trade, then fails one against a full bag. Both times everything goes back. + +Personal shops are tested the same way: `net::personal_shop` covers the +tax, listing, purchase and sale checks, and the sale; `player_interaction` +covers parsing; `runtime::actor::field_shops` covers opening, seats, chat, +purchases, bans, the blacklist, and closing. `personal_shop` (integration) +has two tests: + +- The first is refused a shop off the Free Market. It lists items and takes + one back, opens, and sells to a visitor less tax. It refuses a purchase + of more than is left, then sells out, which closes the shop. Both sides + are saved after a relogin. +- The second bans a visitor, who can't come back. The owner gets the stock + back on leaving and on logging out. diff --git a/db/src/guild.rs b/db/src/guild.rs index bd51eb0ec..ac366b055 100644 --- a/db/src/guild.rs +++ b/db/src/guild.rs @@ -4,7 +4,7 @@ use crate::schema::{characters, guild_members, guilds}; use diesel::prelude::*; use diesel::result::{DatabaseErrorKind, Error as DieselError}; -use game::guild::{Emblem, Guild, GuildMember, GuildTag, MASTER_RANK}; +use game::guild::{Emblem, Guild, GuildMember, GuildTag, MASTER_RANK, MEMBER_RANK}; /// Why founding a guild failed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -14,6 +14,14 @@ pub enum CreateError { AlreadyInGuild, } +/// Why joining a guild failed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JoinError { + /// The guild has as many members as it may. + Full, + AlreadyInGuild, +} + #[derive(Queryable)] struct GuildRow { id: i32, @@ -81,6 +89,56 @@ pub fn create(world_id: u8, name: &str, master: i32) -> crate::Result crate::Result> { + let mut conn = crate::connection()?; + Ok(conn.transaction(|conn| -> QueryResult<_> { + // Lock the guild so members joining at once can't pass its capacity. + let capacity = guilds::table + .find(guild_id) + .select(guilds::capacity) + .for_update() + .first::(conn)?; + let member = guild_members::table + .find(cid) + .select(guild_members::guild_id) + .first::(conn) + .optional()?; + if member.is_some() { + return Ok(Err(JoinError::AlreadyInGuild)); + } + let count: i64 = guild_members::table + .filter(guild_members::guild_id.eq(guild_id)) + .count() + .get_result(conn)?; + if count >= i64::from(capacity) { + return Ok(Err(JoinError::Full)); + } + diesel::insert_into(guild_members::table) + .values(( + guild_members::character_id.eq(cid), + guild_members::guild_id.eq(guild_id), + guild_members::rank.eq(i16::from(MEMBER_RANK)), + )) + .execute(conn)?; + Ok(Ok(())) + })?) +} + +/// Take `cid` out of the guild unless they are its master. Returns whether +/// they were a member it could lose. +pub fn remove_member(guild_id: i32, cid: i32) -> crate::Result { + let mut conn = crate::connection()?; + let removed = diesel::delete( + guild_members::table + .filter(guild_members::character_id.eq(cid)) + .filter(guild_members::guild_id.eq(guild_id)) + .filter(guild_members::rank.ne(i16::from(MASTER_RANK))), + ) + .execute(&mut conn)?; + Ok(removed == 1) +} + /// The guild with its members, the master first. Members are not marked /// online; the world directory knows who is. pub fn load(guild_id: i32) -> crate::Result { @@ -143,6 +201,13 @@ pub fn tag_of(conn: &mut PgConnection, cid: i32) -> QueryResult })) } +/// What others see of `cid`'s guild, read again after someone else changed +/// it. +pub fn tag(cid: i32) -> crate::Result> { + let mut conn = crate::connection()?; + Ok(tag_of(&mut conn, cid)?) +} + /// Every member of the guild, online or not. pub fn member_ids(guild_id: i32) -> crate::Result> { let mut conn = crate::connection()?; diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index b1f416db4..52fe696bb 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -206,6 +206,9 @@ pub struct FieldTemplate { pub field_limit: i32, /// `info/allowedItem`: the items `FIELD_LIMIT_CONSUMABLES` still allows. pub allowed_items: Vec, + /// `info/personalShop`: players may open personal shops here (the Free + /// Market rooms). + pub personal_shop: bool, /// Whether the map has a wall clock (a `clock` node with a position and /// size, as stations do), which the server sets to the time of day. pub clock: bool, @@ -364,14 +367,16 @@ fn build_field_template( .transpose()? .flatten() .unwrap_or(1.0); - let (town, field_limit, allowed_items) = match nx.child_by_name(map_node_idx, "info")? { - Some(info) => ( - nx.int_child(info, "town")?.unwrap_or(0) != 0, - nx.int_child(info, "fieldLimit")?.unwrap_or(0), - allowed_items(nx, info)?, - ), - None => (false, 0, Vec::new()), - }; + let (town, field_limit, allowed_items, personal_shop) = + match nx.child_by_name(map_node_idx, "info")? { + Some(info) => ( + nx.int_child(info, "town")?.unwrap_or(0) != 0, + nx.int_child(info, "fieldLimit")?.unwrap_or(0), + allowed_items(nx, info)?, + nx.int_child(info, "personalShop")?.unwrap_or(0) != 0, + ), + None => (false, 0, Vec::new(), false), + }; // v83 FUN_0052bc72 makes the clock only if all four are non-zero. let clock = match nx.child_by_name(map_node_idx, "clock")? { Some(clock) => { @@ -444,6 +449,7 @@ fn build_field_template( town, field_limit, allowed_items, + personal_shop, clock, footholds, portals_by_id, @@ -929,6 +935,7 @@ mod tests { town: false, field_limit: 0, allowed_items: Vec::new(), + personal_shop: false, clock: false, footholds: footholds::Footholds::default(), portals_by_id: BTreeMap::from([ @@ -997,6 +1004,7 @@ mod tests { town: false, field_limit: 0, allowed_items: Vec::new(), + personal_shop: false, clock: false, reactors: Vec::new(), footholds: footholds::Footholds::default(), diff --git a/game/src/character.rs b/game/src/character.rs index a8e0fa520..b39f64ebf 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -126,6 +126,23 @@ pub struct FieldAvatar { /// The buffs others see, as `Buffs::shown`. pub buffs: Vec, pub guild: Option, + /// The balloon of the mini room the player keeps open, if any. The + /// field sets it. + pub mini_room: Option, +} + +/// The balloon over a player who keeps a mini room open, which others +/// double-click to visit it (v83 UPDATE_CHAR_BOX). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MiniRoomBox { + /// The room type; 4 is a personal shop. + pub kind: u8, + pub sn: i32, + pub title: String, + /// The art: for a shop, its permit's skin. + pub skin: u8, + pub users: u8, + pub max_users: u8, } /// A character that is logged in. The owning client actor holds the only @@ -210,6 +227,7 @@ impl Player { chair: 0, buffs: self.buffs.shown(), guild: self.guild.clone(), + mini_room: None, } } diff --git a/game/src/guild.rs b/game/src/guild.rs index a71a5eb6d..58bd6ea82 100644 --- a/game/src/guild.rs +++ b/game/src/guild.rs @@ -11,6 +11,10 @@ pub const CREATE_COST: i32 = 1_500_000; pub const DEFAULT_TITLES: [&str; 5] = ["Master", "Jr. Master", "Member", "", ""]; /// The master's rank; members range from 1 to 5. pub const MASTER_RANK: u8 = 1; +/// A jr. master, who may invite and expel too. +pub const JR_MASTER_RANK: u8 = 2; +/// The rank new members join at. +pub const MEMBER_RANK: u8 = 5; /// The emblem shown beside the guild name. All zeros means none. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -64,6 +68,31 @@ impl Guild { emblem: self.emblem, } } + + pub fn member(&self, id: i32) -> Option<&GuildMember> { + self.members.iter().find(|member| member.id == id) + } + + /// Whether member `by` may invite someone: a master or jr. master, while + /// there is room. + pub fn may_invite(&self, by: i32) -> bool { + self.member(by) + .is_some_and(|member| member.rank <= JR_MASTER_RANK) + && self.members.len() < usize::from(self.capacity) + } + + /// Whether member `by` may put `member_id` out. Masters and jr. masters + /// may, but nobody expels the master, and only the master expels a + /// jr. master. + pub fn may_expel(&self, by: i32, member_id: i32) -> bool { + let (Some(by), Some(member)) = (self.member(by), self.member(member_id)) else { + return false; + }; + by.id != member.id + && by.rank <= JR_MASTER_RANK + && member.rank != MASTER_RANK + && (member.rank != JR_MASTER_RANK || by.rank == MASTER_RANK) + } } /// Guild names follow the character name rules: 4 to 12 ASCII letters or @@ -76,6 +105,54 @@ pub fn name_is_valid(name: &str) -> bool { mod tests { use super::*; + fn guild(ranks: &[u8]) -> Guild { + Guild { + id: 4, + name: "Testers".into(), + titles: DEFAULT_TITLES.map(String::from), + capacity: 4, + emblem: Emblem::default(), + notice: String::new(), + points: 0, + alliance_id: 0, + members: ranks + .iter() + .enumerate() + .map(|(i, &rank)| GuildMember { + id: i as i32 + 1, + name: format!("Member{i}"), + job: 0, + level: 10, + rank, + online: true, + }) + .collect(), + } + } + + #[test] + fn masters_and_jr_masters_invite_while_there_is_room() { + let testers = guild(&[1, 2, 5]); + assert!(testers.may_invite(1)); + assert!(testers.may_invite(2)); + assert!(!testers.may_invite(3)); + assert!(!testers.may_invite(9)); + assert!(!guild(&[1, 2, 5, 5]).may_invite(1)); + } + + #[test] + fn only_the_master_expels_jr_masters_and_nobody_the_master() { + let testers = guild(&[1, 2, 2, 5, 3]); + assert!(testers.may_expel(1, 2)); + assert!(testers.may_expel(1, 4)); + assert!(testers.may_expel(2, 4)); + assert!(!testers.may_expel(2, 3)); + assert!(!testers.may_expel(2, 1)); + assert!(!testers.may_expel(4, 5)); + assert!(!testers.may_expel(1, 1)); + assert!(!testers.may_expel(1, 9)); + } + #[test] fn names_are_four_to_twelve_letters_or_digits() { assert!(name_is_valid("Testers")); diff --git a/game/src/lib.rs b/game/src/lib.rs index 4c66d98c6..01f4a64d4 100644 --- a/game/src/lib.rs +++ b/game/src/lib.rs @@ -20,7 +20,7 @@ pub mod skill; pub mod storage; pub use buff::{Buff, BuffStat, Buffs, Disease, ShownBuff}; -pub use character::{ApStat, AvatarLook, CharacterStats, FieldAvatar, Player}; +pub use character::{ApStat, AvatarLook, CharacterStats, FieldAvatar, MiniRoomBox, Player}; pub use guild::{Guild, GuildMember, GuildTag}; pub use inventory::{EquipStats, Inventory, InventoryChange, InventoryError, InventoryType, Item}; pub use keymap::{KeyBinding, Keymap}; diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index b62a61330..c88e96dac 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -138,6 +138,8 @@ pub struct Trace { pub storage: Vec, /// Every PLAYER_INTERACTION body (mode first), in order. pub rooms: Vec>, + /// Every UPDATE_CHAR_BOX body (character id first), in order. + pub char_boxes: Vec>, /// GIVE_BUFF: (skill id, value, duration in ms). pub buffs: Vec<(i32, i16, i32)>, /// CANCEL_BUFF: the second mask of each. @@ -440,6 +442,8 @@ impl Trace { self.storage.push(StorageResult::read(mode, &p.bytes[3..])); } else if op == SendOpcode::PlayerInteraction as i16 { self.rooms.push(p.bytes[2..].to_vec()); + } else if op == SendOpcode::UpdateCharBox as i16 { + self.char_boxes.push(p.bytes[2..].to_vec()); } else if op == SendOpcode::GuildOperation as i16 { self.guild_ops.push(p.bytes[2..].to_vec()); } else if op == SendOpcode::GuildNameChanged as i16 { diff --git a/integration-harness/tests/guilds.rs b/integration-harness/tests/guilds.rs index 3de51257c..82a4b597b 100644 --- a/integration-harness/tests/guilds.rs +++ b/integration-harness/tests/guilds.rs @@ -1,13 +1,15 @@ //! Guilds (GUILD_OPERATION): Heracle founds one for a level 10 with the //! fee once the client names it; a bad or taken name asks again. Members //! get their guild on entering the game, others see the guild name under -//! a member's, and guild chat reaches the members online. +//! a member's, and guild chat reaches the members online. The master +//! invites players on the channel, who may decline (DENY_GUILD_REQUEST) or +//! join; members leave, and are expelled. use game_data::GameData; use integration_harness::database::{join_guild, set_job, set_meso}; use integration_harness::preconditions::load_harness_config_or_fail; use integration_harness::quests::answer; -use integration_harness::trace::{read_for, stat, step, until, warp, Trace}; +use integration_harness::trace::{read_for, send, stat, step, until, warp, Trace}; use integration_harness::{login_to_world_session, relogin, HarnessConfig, WorldSession}; use net::packet::op::RecvOpcode; use packet::io::read::PktRead; @@ -19,6 +21,8 @@ use std::sync::OnceLock; use tokio::time::Duration; const GUILD_HQ: i32 = 200_000_301; +/// Orbis Park, away from the other test's founding. +const PARK: i32 = 200_000_200; const HERACLE: i32 = 2010007; const FEE: i32 = 1_500_000; @@ -26,6 +30,12 @@ const NAME_PROMPT: u8 = 0x01; const INFO: u8 = 0x1a; const NAME_TAKEN: u8 = 0x1c; const CREATED: u8 = 0x20; +const INVITE: u8 = 0x05; +const JOINED: u8 = 0x27; +const NOT_IN_CHANNEL: u8 = 0x2a; +const LEFT: u8 = 0x2c; +const EXPELLED: u8 = 0x2f; +const DECLINED: u8 = 0x37; fn maps() -> &'static GameData { static MAPS: OnceLock = OnceLock::new(); @@ -59,6 +69,45 @@ fn create(name: &str) -> Packet { p } +fn operation(mode: u8, ids: &[i32], name: Option<&str>) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::GuildOperation as i16).unwrap(); + p.write_byte(mode).unwrap(); + for &id in ids { + p.write_int(id).unwrap(); + } + if let Some(name) = name { + p.write_str_with_length(name).unwrap(); + } + p +} + +fn decline(inviter: &str, me: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::DenyGuildRequest as i16).unwrap(); + p.write_byte(DECLINED).unwrap(); + p.write_str_with_length(inviter).unwrap(); + p.write_str_with_length(me).unwrap(); + p +} + +/// The guild operation in `mode` `session` gets next. +async fn next_op(session: &mut WorldSession, mode: u8) -> Vec { + let mut t = Trace::default(); + until(&mut session.connection, &mut t, |t| { + t.guild_ops.iter().any(|op| op[0] == mode) + }) + .await; + t.guild_ops.into_iter().find(|op| op[0] == mode).unwrap() +} + +/// A mode's body: an id, another id, then a name. +fn ids_and_name(op: &[u8]) -> (i32, i32, String) { + let mut r = Cursor::new(&op[1..]); + let ids = (r.read_int().unwrap(), r.read_int().unwrap()); + (ids.0, ids.1, r.read_str_with_length().unwrap()) +} + fn guild_chat(to: &[i32], text: &str) -> Packet { let mut p = Packet::new_empty(); p.write_short(RecvOpcode::MultiChat as i16).unwrap(); @@ -171,10 +220,14 @@ async fn heracle_founds_a_guild_that_members_see_and_chat_in() { (name.clone(), vec![(a_id, true)]) ); - // B, on the map, sees the name under A's. + // B, on the map, sees the name under A's. The other test may found a + // guild here meanwhile. let mut bt = Trace::default(); - until(&mut b.connection, &mut bt, |t| !t.guild_names.is_empty()).await; - assert_eq!(bt.guild_names, [(a_id, name.clone())]); + until(&mut b.connection, &mut bt, |t| { + t.guild_names.iter().any(|(id, _)| *id == a_id) + }) + .await; + assert!(bt.guild_names.contains(&(a_id, name.clone()))); // The name is taken, whatever the case. let mut c = founder(&c_config, FEE).await; @@ -184,8 +237,8 @@ async fn heracle_founds_a_guild_that_members_see_and_chat_in() { assert_eq!(t.stat(stat::MESO), None); drop(c); - // B joins (there are no invitations yet) and gets the guild on entering - // the game, with A online. + // B, put in the guild while away, gets it on entering the game, with A + // online. drop(b); tokio::time::sleep(Duration::from_millis(500)).await; join_guild(b_id, &name).await.expect("B joins"); @@ -236,3 +289,141 @@ async fn heracle_founds_a_guild_that_members_see_and_chat_in() { assert_eq!(at.guild_ops[0][..2], [INFO, 1]); assert_eq!(guild_data(&at.guild_ops[0][2..]).0, name); } + +/// A level 1 in the park, seen by `seer`. +async fn visitor(config: &HarnessConfig, seer: &mut WorldSession) -> WorldSession { + let id = new_character(config).await; + let mut session = relogin(config).await; + let mut t = Trace::default(); + assert_eq!(warp(&mut session.connection, PARK, "", &mut t).await, PARK); + let mut seen = Trace::default(); + until(&mut seer.connection, &mut seen, |t| { + t.players.iter().any(|p| p.character_id == id) + }) + .await; + session +} + +#[tokio::test] +async fn members_are_invited_and_leave_or_are_expelled() { + let (a_config, b_config, c_config) = ( + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + ); + let name = a_config.character_name.clone(); + let mut a = founder(&a_config, FEE).await; + ask_heracle(&mut a).await; + send(&mut a.connection, create(&name)).await; + let created = next_op(&mut a, CREATED).await; + let mut r = Cursor::new(&created[1..]); + let guild_id = r.read_int().unwrap(); + let mut t = Trace::default(); + assert_eq!(warp(&mut a.connection, PARK, "", &mut t).await, PARK); + let mut b = visitor(&b_config, &mut a).await; + let mut c = visitor(&c_config, &mut a).await; + let (a_id, b_id, c_id) = (a.character_id, b.character_id, c.character_id); + let (a_name, b_name, c_name) = ( + a.character_name.clone(), + b.character_name.clone(), + c.character_name.clone(), + ); + + // Nobody by that name is on the channel. + send(&mut a.connection, operation(INVITE, &[], Some("Nobody"))).await; + assert_eq!(next_op(&mut a, NOT_IN_CHANNEL).await, [NOT_IN_CHANNEL]); + + // B turns the invitation down, and A hears so. + send(&mut a.connection, operation(INVITE, &[], Some(&b_name))).await; + let invite = next_op(&mut b, INVITE).await; + let mut r = Cursor::new(&invite[1..]); + assert_eq!(r.read_int().unwrap(), guild_id); + assert_eq!(r.read_str_with_length().unwrap(), a_name); + send(&mut b.connection, decline(&a_name, &b_name)).await; + let declined = next_op(&mut a, DECLINED).await; + assert_eq!( + Cursor::new(&declined[1..]).read_str_with_length().unwrap(), + b_name + ); + + // Accepting a declined invitation does nothing; a new one is taken up. + let accept = |id| operation(0x06, &[guild_id, id], None); + step(&mut b.connection, "b stale", accept(b_id)).await; + send(&mut a.connection, operation(INVITE, &[], Some(&b_name))).await; + next_op(&mut b, INVITE).await; + send(&mut b.connection, accept(b_id)).await; + let joined = next_op(&mut b, JOINED).await; + assert_eq!( + joined[1..9], + [guild_id.to_le_bytes(), b_id.to_le_bytes()].concat() + ); + // A hears it, and sees the name under B's, in either order. + let mut at = Trace::default(); + until(&mut a.connection, &mut at, |t| { + t.guild_ops.iter().any(|op| op[0] == JOINED) && !t.guild_names.is_empty() + }) + .await; + assert_eq!(at.guild_names, [(b_id, name.clone())]); + + // B, a plain member, may not invite; A invites C, who joins. + step( + &mut b.connection, + "b invites", + operation(INVITE, &[], Some(&c_name)), + ) + .await; + let mut ct = Trace::default(); + read_for(&mut c.connection, &mut ct, 500).await; + assert!(ct.guild_ops.is_empty()); + send(&mut a.connection, operation(INVITE, &[], Some(&c_name))).await; + next_op(&mut c, INVITE).await; + send(&mut c.connection, accept(c_id)).await; + next_op(&mut c, JOINED).await; + + // B may not expel C; A may, and everyone hears it. + step( + &mut b.connection, + "b expels", + operation(0x08, &[c_id], Some(&c_name)), + ) + .await; + let mut ct = Trace::default(); + read_for(&mut c.connection, &mut ct, 500).await; + assert!(!ct.guild_ops.iter().any(|op| op[0] == EXPELLED)); + send(&mut a.connection, operation(0x08, &[c_id], Some(&c_name))).await; + for session in [&mut b, &mut c] { + let expelled = next_op(session, EXPELLED).await; + assert_eq!(ids_and_name(&expelled), (guild_id, c_id, c_name.clone())); + } + let mut at = Trace::default(); + until(&mut a.connection, &mut at, |t| { + t.guild_ops.iter().any(|op| op[0] == EXPELLED) + && t.guild_names.contains(&(c_id, String::new())) + }) + .await; + + // B leaves; the master can't. + send(&mut b.connection, operation(0x07, &[b_id], Some(&b_name))).await; + for session in [&mut a, &mut b] { + let left = next_op(session, LEFT).await; + assert_eq!(ids_and_name(&left), (guild_id, b_id, b_name.clone())); + } + let t = step( + &mut a.connection, + "a leaves", + operation(0x07, &[a_id], Some(&a_name)), + ) + .await; + assert!(t.guild_ops.is_empty()); + drop((a, b, c)); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Only A is left, and B enters the game without a guild. + let mut a = relogin(&a_config).await; + let info = next_op(&mut a, INFO).await; + assert_eq!(guild_data(&info[2..]), (name, vec![(a_id, true)])); + let mut b = relogin(&b_config).await; + let mut bt = Trace::default(); + read_for(&mut b.connection, &mut bt, 1000).await; + assert!(bt.guild_ops.is_empty()); +} diff --git a/integration-harness/tests/personal_shop.rs b/integration-harness/tests/personal_shop.rs new file mode 100644 index 000000000..4af844291 --- /dev/null +++ b/integration-harness/tests/personal_shop.rs @@ -0,0 +1,325 @@ +//! Personal shops (PLAYER_INTERACTION, mini room type 4): a player with a +//! store permit opens a shop on a Free Market map, lists items in bundles, +//! and sells them to visitors, less tax. The owner can turn a visitor out; +//! whatever is unsold goes back to the owner when the shop closes, or when +//! they log out. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_item, grant_stack, set_meso}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read_for, send, stat, step, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin, HarnessConfig, WorldSession}; +use net::packet::op::RecvOpcode; +use packet::io::read::PktRead; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::Duration; + +const PLAIN_MAP: i32 = 40_000; +const MARKET: i32 = 910_000_001; +const OTHER_MARKET: i32 = 910_000_002; +const STORE_PERMIT: i32 = 5140000; +const RED_POTION: i32 = 2000000; +const SNAIL_SHELL: i32 = 4000000; +const USE: u8 = 2; +const ETC: u8 = 4; +const CASH: u8 = 5; + +fn interaction(mode: u8) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::PlayerInteraction as i16).unwrap(); + p.write_byte(mode).unwrap(); + p +} + +fn create(title: &str) -> Packet { + let mut p = interaction(0); + p.write_byte(4).unwrap(); + p.write_str_with_length(title).unwrap(); + p.write_byte(0).unwrap(); + p.write_short(1).unwrap(); + p.write_int(STORE_PERMIT).unwrap(); + p +} + +fn put_item(kind: u8, slot: i16, bundles: i16, per_bundle: i16, price: i32) -> Packet { + let mut p = interaction(0x16); + p.write_byte(kind).unwrap(); + p.write_short(slot).unwrap(); + p.write_short(bundles).unwrap(); + p.write_short(per_bundle).unwrap(); + p.write_int(price).unwrap(); + p +} + +fn take_back(index: i16) -> Packet { + let mut p = interaction(0x1b); + p.write_short(index).unwrap(); + p +} + +/// The client sends its blacklist, empty here, and then opens. +async fn open(c: &mut MapleTestConnection) { + let mut p = interaction(0x1e); + p.write_short(0).unwrap(); + send(c, p).await; + let mut p = interaction(0xb); + p.write_byte(1).unwrap(); + send(c, p).await; +} + +fn visit(room: i32) -> Packet { + let mut p = interaction(4); + p.write_int(room).unwrap(); + p +} + +fn buy(index: u8, bundles: i16) -> Packet { + let mut p = interaction(0x17); + p.write_byte(index).unwrap(); + p.write_short(bundles).unwrap(); + p.write_int(0).unwrap(); + p +} + +fn ban(slot: u8, name: &str) -> Packet { + let mut p = interaction(0x1c); + p.write_byte(slot).unwrap(); + p.write_str_with_length(name).unwrap(); + p +} + +fn leave() -> Packet { + interaction(0xa) +} + +/// The first listing of each item list: (bundles left, per bundle, +/// price). +fn first_listings(t: &Trace) -> Vec<(i16, i16, i32)> { + t.rooms + .iter() + .filter(|b| b[0] == 0x19 && b[1] > 0) + .map(|b| { + let mut r = &b[2..]; + ( + r.read_short().unwrap(), + r.read_short().unwrap(), + r.read_int().unwrap(), + ) + }) + .collect() +} + +/// The number of listings in each item list. +fn listing_counts(t: &Trace) -> Vec { + t.rooms + .iter() + .filter(|b| b[0] == 0x19) + .map(|b| b[1]) + .collect() +} + +/// The shop room id and skin in the balloon over `owner`, once it shows. +async fn balloon(c: &mut MapleTestConnection, owner: i32) -> (i32, u8) { + let mut t = Trace::default(); + let over = |b: &Vec| (&b[..4]).read_int().unwrap() == owner && b[4] == 4; + until(c, &mut t, |t| t.char_boxes.iter().any(over)).await; + let body = t.char_boxes.iter().rfind(|b| over(b)).unwrap(); + let mut r = &body[5..]; + let room = r.read_int().unwrap(); + r.read_str_with_length().unwrap(); + assert_eq!(r.read_byte().unwrap(), 0, "a password"); + (room, r.read_byte().unwrap()) +} + +/// Read into `t` until the balloon over `owner` goes. +async fn balloon_gone(c: &mut MapleTestConnection, t: &mut Trace, owner: i32) { + let mut gone = owner.to_le_bytes().to_vec(); + gone.push(0); + until(c, t, |t| t.char_boxes.contains(&gone)).await; +} + +async fn new_character(config: &HarnessConfig) -> i32 { + let session = login_to_world_session(config).await.expect("first login"); + let id = session.character_id; + drop(session); + drop(relogin(config).await); + id +} + +async fn meet(a: &mut WorldSession, b: &mut WorldSession, map: i32) { + let (mut at, mut bt) = (Trace::default(), Trace::default()); + assert_eq!(warp(&mut a.connection, map, "", &mut at).await, map); + assert_eq!(warp(&mut b.connection, map, "", &mut bt).await, map); + read_for(&mut a.connection, &mut at, 300).await; + read_for(&mut b.connection, &mut bt, 300).await; +} + +/// Read into `t` until it has a room packet starting with `head`. +async fn more(c: &mut MapleTestConnection, t: &mut Trace, head: &[u8]) { + until(c, t, |t| t.rooms.iter().any(|b| b.starts_with(head))).await; +} + +/// Wait until a room packet starting with `head` arrives. +async fn room_packet(c: &mut MapleTestConnection, head: &[u8]) -> Trace { + let mut t = Trace::default(); + more(c, &mut t, head).await; + t +} + +#[tokio::test] +async fn a_shop_sells_its_stock_less_tax_and_closes_when_sold_out() { + let (a_config, b_config) = ( + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + ); + let a_id = new_character(&a_config).await; + let b_id = new_character(&b_config).await; + tokio::time::sleep(Duration::from_millis(500)).await; + set_meso(a_id, 1_000).await.expect("give A mesos"); + grant_item(a_id, CASH.into(), 1, STORE_PERMIT) + .await + .expect("give A a permit"); + grant_stack(a_id, ETC.into(), 1, SNAIL_SHELL, 20) + .await + .expect("give A snail shells"); + grant_stack(a_id, USE.into(), 1, RED_POTION, 3) + .await + .expect("give A red potions"); + set_meso(b_id, 300_000).await.expect("give B mesos"); + let mut a = relogin(&a_config).await; + let mut b = relogin(&b_config).await; + + // Only the Free Market allows shops. + let mut at = Trace::default(); + assert_eq!( + warp(&mut a.connection, PLAIN_MAP, "", &mut at).await, + PLAIN_MAP + ); + let t = step(&mut a.connection, "plain", create("Shells")).await; + assert_eq!(t.rooms, [vec![5, 0, 0xf]]); + + meet(&mut a, &mut b, MARKET).await; + let t = step(&mut a.connection, "create", create("Shells")).await; + assert_eq!(t.rooms.len(), 1, "{:?}", t.rooms); + assert_eq!(t.rooms[0][..4], [5, 4, 4, 0]); + + // Two bundles of five shells at 100,000 each, and the potions, which + // A takes back before opening. + let t = step(&mut a.connection, "shells", put_item(ETC, 1, 2, 5, 100_000)).await; + assert_eq!(t.quantities, [(ETC, 1, 10)]); + assert_eq!(first_listings(&t), [(2, 5, 100_000)]); + let t = step(&mut a.connection, "potions", put_item(USE, 1, 1, 3, 50)).await; + assert_eq!(t.removed, [(USE, 1)]); + assert_eq!(listing_counts(&t), [2]); + let t = step(&mut a.connection, "take-back", take_back(1)).await; + assert!(t + .added + .iter() + .any(|&(kind, _, id)| (kind, id) == (USE, RED_POTION))); + assert_eq!(listing_counts(&t), [1]); + + // Opening puts a balloon over A, which B uses to come in. + open(&mut a.connection).await; + let (room, skin) = balloon(&mut b.connection, a_id).await; + assert_eq!(skin, 0); + let t = step(&mut b.connection, "visit", visit(room)).await; + assert_eq!(t.rooms.len(), 1, "{:?}", t.rooms); + assert_eq!(t.rooms[0][..4], [5, 4, 4, 1]); + room_packet(&mut a.connection, &[4, 1]).await; + + // B buys a bundle; A gets the price less 0.4% tax. The new list comes + // from the owner, maybe after B's own answer. + let mut t = step(&mut b.connection, "buy", buy(0, 1)).await; + assert_eq!(t.stat(stat::MESO), Some(200_000)); + assert!(t + .added + .iter() + .any(|&(kind, _, id)| (kind, id) == (ETC, SNAIL_SHELL))); + more(&mut b.connection, &mut t, &[0x19]).await; + assert_eq!(first_listings(&t), [(1, 5, 100_000)]); + let mut at = room_packet(&mut a.connection, &[0x1a, 0, 1, 0]).await; + until(&mut a.connection, &mut at, |t| t.stat(stat::MESO).is_some()).await; + assert_eq!(at.stat(stat::MESO), Some(1_000 + 99_600)); + + // There is only one bundle left. + let t = step(&mut b.connection, "too-many", buy(0, 2)).await; + assert_eq!(t.rooms, [vec![0x18, 1]]); + assert_eq!(t.stat(stat::MESO), None); + + // The last bundle sells out the shop, which closes for both. + let mut t = step(&mut b.connection, "last", buy(0, 1)).await; + assert_eq!(t.stat(stat::MESO), Some(100_000)); + more(&mut b.connection, &mut t, &[0xa, 1, 0xe]).await; + let at = room_packet(&mut a.connection, &[0xa, 0, 0xe]).await; + assert_eq!(at.stat(stat::MESO), Some(1_000 + 2 * 99_600)); + balloon_gone(&mut b.connection, &mut t, a_id).await; + let t = step(&mut b.connection, "closed", visit(room)).await; + assert_eq!(t.rooms, [vec![5, 0, 1]]); + drop(a); + drop(b); + + let a = relogin(&a_config).await; + assert_eq!(a.meso, 1_000 + 2 * 99_600); + assert_eq!(a.items.get(&SNAIL_SHELL), Some(&10)); + assert_eq!(a.items.get(&RED_POTION), Some(&3)); + let b = relogin(&b_config).await; + assert_eq!(b.meso, 100_000); + assert_eq!(b.items.get(&SNAIL_SHELL), Some(&10)); +} + +#[tokio::test] +async fn the_owner_bans_visitors_and_gets_unsold_stock_back() { + let (a_config, b_config) = ( + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + ); + let a_id = new_character(&a_config).await; + let b_id = new_character(&b_config).await; + tokio::time::sleep(Duration::from_millis(500)).await; + grant_item(a_id, CASH.into(), 1, STORE_PERMIT) + .await + .expect("give A a permit"); + grant_stack(a_id, ETC.into(), 1, SNAIL_SHELL, 20) + .await + .expect("give A snail shells"); + set_meso(b_id, 1_000).await.expect("give B mesos"); + let mut a = relogin(&a_config).await; + let mut b = relogin(&b_config).await; + meet(&mut a, &mut b, OTHER_MARKET).await; + + step(&mut a.connection, "create", create("Shells")).await; + let t = step(&mut a.connection, "shells", put_item(ETC, 1, 20, 1, 10)).await; + assert_eq!(t.removed, [(ETC, 1)]); + open(&mut a.connection).await; + let (room, _) = balloon(&mut b.connection, a_id).await; + step(&mut b.connection, "visit", visit(room)).await; + room_packet(&mut a.connection, &[4, 1]).await; + + // Turned out, B may not come back while the shop is open. + send(&mut a.connection, ban(1, &b.character_name)).await; + room_packet(&mut b.connection, &[0xa, 1, 5]).await; + room_packet(&mut a.connection, &[0xa, 1]).await; + let t = step(&mut b.connection, "banned", visit(room)).await; + assert_eq!(t.rooms, [vec![5, 0, 0x11]]); + + // Closing the shop gives A the shells back. + let t = step(&mut a.connection, "close", leave()).await; + assert!(t + .added + .iter() + .any(|&(kind, _, id)| (kind, id) == (ETC, SNAIL_SHELL))); + balloon_gone(&mut b.connection, &mut Trace::default(), a_id).await; + + // So does logging out with the shop open. + step(&mut a.connection, "create", create("Again")).await; + step(&mut a.connection, "shells", put_item(ETC, 1, 4, 5, 10)).await; + open(&mut a.connection).await; + balloon(&mut b.connection, a_id).await; + drop(a); + balloon_gone(&mut b.connection, &mut Trace::default(), a_id).await; + drop(b); + + let a = relogin(&a_config).await; + assert_eq!(a.items.get(&SNAIL_SHELL), Some(&20)); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 8e44a73de..97ef8a4a7 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -236,9 +236,31 @@ pub enum GuildRequest { /// Send the player's guild again (`GUILD_OPERATION` mode 0). Reload, /// Found a guild with this name, answering the name prompt. - Create { name: String }, + Create { + name: String, + }, /// Say something to the guild members online. - Chat { text: String }, + Chat { + text: String, + }, + /// Ask the character with this name to join the guild. + Invite { + name: String, + }, + /// Join the guild that invited the player. + Accept { + guild_id: i32, + }, + /// Turn down the invitation `inviter` sent. + Deny { + inviter: String, + refusal: crate::packet::build::world::guild::InviteRefusal, + }, + Leave, + /// Put a member out of the guild. + Expel { + member_id: i32, + }, } /// Something a player asks of their Maple Messenger, which the world @@ -300,6 +322,8 @@ pub enum WorldAction { Storage(crate::storage::StorageRequest), /// Open, join, or act in a trading room. Trade(crate::trade::TradeRequest), + /// Open, stock, visit, or buy from a personal shop. + PersonalShop(crate::personal_shop::PersonalShopRequest), /// The player touched the named script portal on their map. ScriptPortal { portal: String, @@ -563,6 +587,10 @@ fn world_routes() -> &'static HashMap { (Op::MultiChat, InGame(&world::MultiChatHandler)), (Op::BuddyListModify, InGame(&world::BuddyListModifyHandler)), (Op::GuildOperation, InGame(&world::GuildOperationHandler)), + ( + Op::DenyGuildRequest, + InGame(&world::DenyGuildRequestHandler), + ), (Op::Messenger, InGame(&world::MessengerHandler)), ( Op::PlayerInteraction, @@ -650,7 +678,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(), 61); + assert_eq!(world_routes().len(), 62); } #[test] diff --git a/net/src/lib.rs b/net/src/lib.rs index 48cbde63a..f690fdffa 100644 --- a/net/src/lib.rs +++ b/net/src/lib.rs @@ -23,6 +23,7 @@ pub mod monster_book; mod npc_data; pub mod npc_scripts; pub mod packet; +pub mod personal_shop; pub mod policy; pub mod quests; pub mod reactors; diff --git a/net/src/packet/build/world/field.rs b/net/src/packet/build/world/field.rs index acb08571a..680c8aad3 100644 --- a/net/src/packet/build/world/field.rs +++ b/net/src/packet/build/world/field.rs @@ -1,5 +1,6 @@ use super::buff; use super::guild::write_emblem; +use super::mini_room::write_mini_room_box; use crate::codec::encode_avatar_look; use crate::{error::NetworkError, packet::op::SendOpcode}; use game::FieldAvatar; @@ -40,7 +41,7 @@ pub fn build_player_enter_field(avatar: &FieldAvatar) -> Result Option { + match code { + 0x35 => Some(Self::Busy), + 0x36 => Some(Self::AnotherInvitation), + 0x37 => Some(Self::Declined), + _ => None, + } + } } fn guild_operation(op: u8) -> Result { @@ -48,6 +83,18 @@ pub(crate) fn write_emblem(packet: &mut Packet, emblem: &Emblem) -> Result<(), N Ok(()) } +/// A member's 37-byte record. +fn write_member(packet: &mut Packet, member: &GuildMember) -> Result<(), NetworkError> { + write_padded_name(packet, &member.name)?; + packet.write_int(i32::from(member.job))?; + packet.write_int(i32::from(member.level))?; + packet.write_int(i32::from(member.rank))?; + packet.write_int(i32::from(member.online))?; + packet.write_int(0)?; // contribution + packet.write_int(ALLIANCE_RANK)?; + Ok(()) +} + /// `GUILDDATA`: the guild, its titles, and its members, each as a 37-byte /// record. The client takes the first member for the master. fn write_guild(packet: &mut Packet, guild: &Guild) -> Result<(), NetworkError> { @@ -61,13 +108,7 @@ fn write_guild(packet: &mut Packet, guild: &Guild) -> Result<(), NetworkError> { packet.write_int(member.id)?; } for member in &guild.members { - write_padded_name(packet, &member.name)?; - packet.write_int(i32::from(member.job))?; - packet.write_int(i32::from(member.level))?; - packet.write_int(i32::from(member.rank))?; - packet.write_int(i32::from(member.online))?; - packet.write_int(0)?; // contribution - packet.write_int(ALLIANCE_RANK)?; + write_member(packet, member)?; } packet.write_int(i32::from(guild.capacity))?; write_emblem(packet, &guild.emblem)?; @@ -104,6 +145,58 @@ pub fn build_guild_error(error: GuildError) -> Result { guild_operation(error as u8) } +/// `inviter` asks the player to join guild `guild_id`; the client answers +/// with `GUILD_OPERATION` mode 6 or `DENY_GUILD_REQUEST`. +pub fn build_guild_invite(guild_id: i32, inviter: &str) -> Result { + let mut packet = guild_operation(OP_INVITE)?; + packet.write_int(guild_id)?; + packet.write_str_with_length(inviter)?; + Ok(packet) +} + +/// `name` turned the player's invitation down. +pub fn build_invite_refused(refusal: InviteRefusal, name: &str) -> Result { + let mut packet = guild_operation(refusal as u8)?; + packet.write_str_with_length(name)?; + Ok(packet) +} + +/// `member` joined. The members add them to their list; the newcomer's +/// client skips the record and asks for the whole guild. +pub fn build_member_joined(guild_id: i32, member: &GuildMember) -> Result { + let mut packet = guild_operation(OP_JOINED)?; + packet.write_int(guild_id)?; + packet.write_int(member.id)?; + write_member(&mut packet, member)?; + Ok(packet) +} + +/// Member `member_id` left the guild. +pub fn build_member_left( + guild_id: i32, + member_id: i32, + name: &str, +) -> Result { + departure(OP_LEFT, guild_id, member_id, name) +} + +/// Member `member_id` was put out of the guild. +pub fn build_member_expelled( + guild_id: i32, + member_id: i32, + name: &str, +) -> Result { + departure(OP_EXPELLED, guild_id, member_id, name) +} + +fn departure(op: u8, guild_id: i32, member_id: i32, name: &str) -> Result { + let mut packet = guild_operation(op)?; + packet.write_int(guild_id)?; + packet.write_int(member_id)?; + packet.write_str_with_length(name)?; + Ok(packet) +} + pub fn build_guild_chat(name: &str, text: &str) -> Result { let mut packet = Packet::new_empty(); packet.write_short(SendOpcode::MultiChat as i16)?; @@ -240,12 +333,76 @@ mod tests { (build_guild_error(GuildError::AlreadyInGuild), 0x21), (build_guild_error(GuildError::LevelTooLow), 0x23), (build_guild_error(GuildError::Admin), 0x38), + (build_guild_error(GuildError::InviteeInGuild), 0x28), + (build_guild_error(GuildError::Full), 0x29), + (build_guild_error(GuildError::NotInChannel), 0x2A), + ] { + let packet = packet.unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect + .short(SendOpcode::GuildOperation as i16) + .byte(mode) + .end(); + } + } + + #[test] + fn invite_and_refusals() { + let packet = build_guild_invite(4, "Alice").unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect + .short(SendOpcode::GuildOperation as i16) + .byte(0x05) + .int(4) + .string("Alice") + .end(); + + for (refusal, mode) in [ + (InviteRefusal::Busy, 0x35), + (InviteRefusal::AnotherInvitation, 0x36), + (InviteRefusal::Declined, 0x37), + ] { + assert_eq!(InviteRefusal::from_code(mode), Some(refusal)); + let packet = build_invite_refused(refusal, "Bobby").unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect + .short(SendOpcode::GuildOperation as i16) + .byte(mode) + .string("Bobby") + .end(); + } + } + + #[test] + fn joined_carries_the_member_and_departures_the_name() { + let packet = build_member_joined(4, &guild().members[1]).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect + .short(SendOpcode::GuildOperation as i16) + .byte(0x27) + .int(4) + .int(2) + .bytes(&padded("Bobby", 13)) + .int(100) + .int(30) + .int(3) + .int(0) + .int(0) + .int(5) + .end(); + + for (packet, mode) in [ + (build_member_left(4, 2, "Bobby"), 0x2C), + (build_member_expelled(4, 2, "Bobby"), 0x2F), ] { let packet = packet.unwrap(); let mut expect = Expect::new(&packet.bytes); expect .short(SendOpcode::GuildOperation as i16) .byte(mode) + .int(4) + .int(2) + .string("Bobby") .end(); } } diff --git a/net/src/packet/build/world/mini_room.rs b/net/src/packet/build/world/mini_room.rs index 80cc132ab..37ad2468a 100644 --- a/net/src/packet/build/world/mini_room.rs +++ b/net/src/packet/build/world/mini_room.rs @@ -1,10 +1,12 @@ -//! Mini rooms (v83 `PLAYER_INTERACTION`): for now only the trading room. -//! Every packet starts with a mode byte. +//! Mini rooms (v83 `PLAYER_INTERACTION`): the trading room and personal +//! shops. Every packet starts with a mode byte. Also the balloon over a +//! player who keeps a room open (UPDATE_CHAR_BOX). use crate::codec::encode_avatar_look; use crate::codec::item::{encode_item, SlotPrefix}; +use crate::personal_shop::Listing; use crate::{error::NetworkError, packet::op::SendOpcode}; -use game::{AvatarLook, Item}; +use game::{AvatarLook, Item, MiniRoomBox}; use packet::{io::write::PktWrite, Packet}; /// Server → client modes. @@ -18,11 +20,20 @@ const PUT_ITEM_MODE: u8 = 0x0f; const PUT_MESO_MODE: u8 = 0x10; const CONFIRMED_MODE: u8 = 0x11; const MESO_LIMIT_MODE: u8 = 0x15; +const BUY_RESULT_MODE: u8 = 0x18; +const SHOP_ITEMS_MODE: u8 = 0x19; +const SOLD_MODE: u8 = 0x1a; /// The trading room's type and size. pub const TRADE_ROOM: u8 = 3; const TRADE_USERS: u8 = 2; +/// The personal shop's type and size: the owner and three visitors. +pub const PERSONAL_SHOP: u8 = 4; +pub const SHOP_USERS: u8 = 4; +/// How many items a personal shop lists at most. +pub const SHOP_ITEMS: u8 = 16; + /// A chat line from a member (7 would be a game notice). const CHAT_LINE: u8 = 8; @@ -42,6 +53,13 @@ pub mod invite_result { pub mod enter_error { /// "The room is already closed." pub const CLOSED: u8 = 1; + /// "You can't enter the room due to full capacity." + pub const FULL: u8 = 2; + /// "The personal store can only be built at the free market available + /// at main towns." + pub const NOT_FREE_MARKET: u8 = 0x0f; + /// "You may not enter this store." + pub const BANNED: u8 = 0x11; } /// How a trade ended, shown to the member who stays (LEAVE). @@ -54,6 +72,30 @@ pub mod leave_reason { pub const FAILED: u8 = 8; /// One of the items is one-of-a-kind and the receiver has one. pub const ONE_OF_A_KIND: u8 = 9; + /// "The shop is closed." + pub const SHOP_CLOSED: u8 = 3; + /// "The user has been banned." + pub const BANNED: u8 = 5; + /// "The items are out of stock." + pub const OUT_OF_STOCK: u8 = 0x0e; + /// Banned for staying an hour; may not come back. + pub const OVERSTAYED: u8 = 0x0f; +} + +/// Why a purchase in a personal shop failed (BUY_RESULT). +pub mod buy_result { + /// "You don't have enough in stock." + pub const NO_STOCK: u8 = 1; + /// "You do not have enough mesos." + pub const NO_MESOS: u8 = 2; + /// "The price of the item is too high for the trade." + pub const TOO_PRICEY: u8 = 3; + /// "Please check if your inventory is full or not." + pub const INVENTORY_FULL: u8 = 5; + /// "You cannot carry more than one for this item." + pub const ONLY_ONE: u8 = 6; + /// "Level 15 and below may only trade 1 million mesos per day..." + pub const LOW_LEVEL_LIMIT: u8 = 0x0e; } fn room_packet(mode: u8) -> Result { @@ -87,15 +129,17 @@ pub fn build_invite_result(result: u8, target: &str) -> Result Result { let mut packet = room_packet(ENTER_MODE)?; - packet.write_byte(TRADE_ROOM)?; - packet.write_byte(TRADE_USERS)?; + packet.write_byte(kind)?; + packet.write_byte(users)?; packet.write_byte(my_slot)?; for &(slot, look, name) in members { packet.write_byte(slot)?; @@ -106,6 +150,97 @@ pub fn build_trade_room( Ok(packet) } +/// The receiving client enters a trading room in `my_slot`, with the +/// members already in it as `(slot, look, name)`. +pub fn build_trade_room( + my_slot: u8, + members: &[(u8, &AvatarLook, &str)], +) -> Result { + enter_room(TRADE_ROOM, TRADE_USERS, my_slot, members) +} + +/// The receiving client enters personal shop `title` in `my_slot` (0 for +/// the owner), with its members and what it lists. +pub fn build_shop_room( + my_slot: u8, + members: &[(u8, &AvatarLook, &str)], + title: &str, + listings: &[Listing], +) -> Result { + let mut packet = enter_room(PERSONAL_SHOP, SHOP_USERS, my_slot, members)?; + packet.write_str_with_length(title)?; + packet.write_byte(SHOP_ITEMS)?; + write_listings(&mut packet, listings)?; + Ok(packet) +} + +/// Each listing: bundles left (0 when sold out), items per bundle, the +/// price of a bundle, then one bundle as an item. +fn write_listings(packet: &mut Packet, listings: &[Listing]) -> Result<(), NetworkError> { + packet.write_byte(listings.len() as u8)?; + for listing in listings { + packet.write_short(listing.bundles)?; + packet.write_short(listing.item.quantity)?; + packet.write_int(listing.price)?; + encode_item(packet, SlotPrefix::None, &listing.item)?; + } + Ok(()) +} + +/// What a personal shop lists now. +pub fn build_shop_items(listings: &[Listing]) -> Result { + let mut packet = room_packet(SHOP_ITEMS_MODE)?; + write_listings(&mut packet, listings)?; + Ok(packet) +} + +/// Tells the owner `buyer` bought `bundles` of listing `index`; the client +/// keeps a record for the sold-items window. +pub fn build_shop_sold(index: u8, bundles: i16, buyer: &str) -> Result { + let mut packet = room_packet(SOLD_MODE)?; + packet.write_byte(index)?; + packet.write_short(bundles)?; + packet.write_str_with_length(buyer)?; + Ok(packet) +} + +/// Why the receiving client's purchase failed (`buy_result`). +pub fn build_buy_result(result: u8) -> Result { + let mut packet = room_packet(BUY_RESULT_MODE)?; + packet.write_byte(result)?; + Ok(packet) +} + +/// The balloon block of UPDATE_CHAR_BOX and the player spawn: the room +/// type, 0 for none, then the room. +pub fn write_mini_room_box( + packet: &mut Packet, + room: Option<&MiniRoomBox>, +) -> Result<(), NetworkError> { + let Some(room) = room else { + packet.write_byte(0)?; + return Ok(()); + }; + packet.write_byte(room.kind)?; + packet.write_int(room.sn)?; + packet.write_str_with_length(&room.title)?; + packet.write_byte(0)?; // no password + packet.write_byte(room.skin)?; + packet.write_byte(room.users)?; + packet.write_byte(room.max_users)?; + packet.write_byte(0)?; // no game in progress + Ok(()) +} + +/// UPDATE_CHAR_BOX: the balloon over `char_id` changed, or went (`None`). +pub fn build_char_box(char_id: i32, room: Option<&MiniRoomBox>) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::UpdateCharBox as i16)?; + packet.write_int(char_id)?; + write_mini_room_box(&mut packet, room)?; + Ok(packet) +} + /// The receiving client couldn't enter the room (`enter_error`). pub fn build_enter_error(error: u8) -> Result { let mut packet = room_packet(ENTER_MODE)?; @@ -141,6 +276,13 @@ pub fn build_room_leave(slot: u8, reason: u8) -> Result { Ok(packet) } +/// The member in `slot` left; for the others in the room. +pub fn build_seat_left(slot: u8) -> Result { + let mut packet = room_packet(LEAVE_MODE)?; + packet.write_byte(slot)?; + Ok(packet) +} + /// An item went into trade slot `slot`, on the receiver's own side /// (`partner` false) or the partner's. pub fn build_trade_item(partner: bool, slot: u8, item: &Item) -> Result { @@ -291,6 +433,85 @@ mod tests { .end(); } + #[test] + fn a_shop_enter_adds_the_title_and_listings() { + let alice = look(30000); + let potions = Listing { + item: Item::stack(2000000, 10), + bundles: 3, + price: 500, + }; + let mut encoded = Packet::new_empty(); + encode_item(&mut encoded, SlotPrefix::None, &potions.item).unwrap(); + let packet = build_shop_room(0, &[(0, &alice, "Alice")], "Cheap!", &[potions]).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(OP).byte(0x05).byte(4).byte(4).byte(0); + expect + .byte(0) + .look(&alice, false) + .string("Alice") + .byte(0xFF); + expect.string("Cheap!").byte(16).byte(1); + expect + .short(3) + .short(10) + .int(500) + .bytes(&encoded.bytes) + .end(); + + let items = build_shop_items(&[]).unwrap(); + Expect::new(&items.bytes).short(OP).byte(0x19).byte(0).end(); + } + + #[test] + fn sales_and_failed_purchases() { + let sold = build_shop_sold(2, 5, "Bob").unwrap(); + Expect::new(&sold.bytes) + .short(OP) + .byte(0x1a) + .byte(2) + .short(5) + .string("Bob") + .end(); + let failed = build_buy_result(buy_result::NO_MESOS).unwrap(); + Expect::new(&failed.bytes) + .short(OP) + .byte(0x18) + .byte(2) + .end(); + } + + #[test] + fn the_balloon_names_the_room_and_its_seats() { + let shop = MiniRoomBox { + kind: PERSONAL_SHOP, + sn: 77, + title: "Cheap!".into(), + skin: 2, + users: 1, + max_users: 4, + }; + let packet = build_char_box(9, Some(&shop)).unwrap(); + Expect::new(&packet.bytes) + .short(SendOpcode::UpdateCharBox as i16) + .int(9) + .byte(4) + .int(77) + .string("Cheap!") + .byte(0) + .byte(2) + .byte(1) + .byte(4) + .byte(0) + .end(); + let gone = build_char_box(9, None).unwrap(); + Expect::new(&gone.bytes) + .short(SendOpcode::UpdateCharBox as i16) + .int(9) + .byte(0) + .end(); + } + #[test] fn confirmed_and_meso_limit_are_bare_modes() { let confirmed = build_trade_confirmed().unwrap(); diff --git a/net/src/packet/handle/world/guild.rs b/net/src/packet/handle/world/guild.rs index 7381f166e..998b570d2 100644 --- a/net/src/packet/handle/world/guild.rs +++ b/net/src/packet/handle/world/guild.rs @@ -1,11 +1,13 @@ use crate::handler::{ GuildRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, }; +use crate::packet::build::world::guild::InviteRefusal; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; -/// `GUILD_OPERATION`: ask for the guild again, or name a new guild in -/// answer to the name prompt. Other modes aren't supported yet. +/// `GUILD_OPERATION`: ask for the guild again, name a new guild in answer +/// to the name prompt, invite, join, leave, or expel. Other modes aren't +/// supported yet. pub struct GuildOperationHandler; impl WorldHandler for GuildOperationHandler { @@ -17,19 +19,52 @@ impl WorldHandler for GuildOperationHandler { 0x02 => GuildRequest::Create { name: reader.read_str_with_length()?, }, + 0x05 => GuildRequest::Invite { + name: reader.read_str_with_length()?, + }, + // The player's id follows; the server knows it. + 0x06 => GuildRequest::Accept { + guild_id: reader.read_int()?, + }, + // The player's id and name follow. + 0x07 => GuildRequest::Leave, + // The member's name follows. + 0x08 => GuildRequest::Expel { + member_id: reader.read_int()?, + }, _ => return Ok(HandlerResult::empty()), }; Ok(HandlerResult::empty().with(WorldAction::Guild(request))) } } +/// `DENY_GUILD_REQUEST`: the invitee turned an invitation down, or their +/// client did because they were busy. +pub struct DenyGuildRequestHandler; + +impl WorldHandler for DenyGuildRequestHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let Some(refusal) = InviteRefusal::from_code(reader.read_byte()?) else { + return Ok(HandlerResult::empty()); + }; + // The invitee's own name follows. + let inviter = reader.read_str_with_length()?; + Ok( + HandlerResult::empty() + .with(WorldAction::Guild(GuildRequest::Deny { inviter, refusal })), + ) + } +} + #[cfg(test)] mod tests { use super::*; use crate::packet::op::RecvOpcode; use packet::io::write::PktWrite; - fn request(bytes: &[u8]) -> Option { + fn request(handler: &dyn WorldHandler, bytes: &[u8]) -> Option { let mut packet = Packet::new(bytes); let mut player = crate::codec::stats::sample_player(); let mut ctx = WorldContext { @@ -37,7 +72,7 @@ mod tests { channel_id: 0, player: &mut player, }; - let result = GuildOperationHandler.handle(&mut packet, &mut ctx); + let result = handler.handle(&mut packet, &mut ctx); match result.unwrap().actions.pop() { Some(WorldAction::Guild(request)) => Some(request), None => None, @@ -54,22 +89,88 @@ mod tests { packet } + fn guild_request(packet: &Packet) -> Option { + request(&GuildOperationHandler, &packet.bytes) + } + #[test] fn reads_reload_and_create() { - assert_eq!(request(&operation(0).bytes), Some(GuildRequest::Reload)); + assert_eq!(guild_request(&operation(0)), Some(GuildRequest::Reload)); let mut packet = operation(2); packet.write_str_with_length("Testers").unwrap(); assert_eq!( - request(&packet.bytes), + guild_request(&packet), Some(GuildRequest::Create { name: "Testers".into() }) ); - // Invitations and the rest come later. + // Rank changes and the rest come later. + let mut packet = operation(0x0E); + packet.write_int(7).unwrap(); + packet.write_byte(3).unwrap(); + assert_eq!(guild_request(&packet), None); + } + + #[test] + fn reads_invite_accept_leave_and_expel() { let mut packet = operation(5); packet.write_str_with_length("Bobby").unwrap(); - assert_eq!(request(&packet.bytes), None); + assert_eq!( + guild_request(&packet), + Some(GuildRequest::Invite { + name: "Bobby".into() + }) + ); + + let mut packet = operation(6); + packet.write_int(4).unwrap(); + packet.write_int(9).unwrap(); + assert_eq!( + guild_request(&packet), + Some(GuildRequest::Accept { guild_id: 4 }) + ); + + let mut packet = operation(7); + packet.write_int(9).unwrap(); + packet.write_str_with_length("Bobby").unwrap(); + assert_eq!(guild_request(&packet), Some(GuildRequest::Leave)); + + let mut packet = operation(8); + packet.write_int(9).unwrap(); + packet.write_str_with_length("Bobby").unwrap(); + assert_eq!( + guild_request(&packet), + Some(GuildRequest::Expel { member_id: 9 }) + ); + } + + #[test] + fn reads_refusals() { + let deny = |reason: u8| { + let mut packet = Packet::new_empty(); + packet + .write_short(RecvOpcode::DenyGuildRequest as i16) + .unwrap(); + packet.write_byte(reason).unwrap(); + packet.write_str_with_length("Alice").unwrap(); + packet.write_str_with_length("Bobby").unwrap(); + request(&DenyGuildRequestHandler, &packet.bytes) + }; + for (reason, refusal) in [ + (0x35, InviteRefusal::Busy), + (0x36, InviteRefusal::AnotherInvitation), + (0x37, InviteRefusal::Declined), + ] { + assert_eq!( + deny(reason), + Some(GuildRequest::Deny { + inviter: "Alice".into(), + refusal + }) + ); + } + assert_eq!(deny(0x2A), None); } } diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 3b8984ecd..01f4642a3 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -52,7 +52,7 @@ pub use self::conti::ContiStateRequestHandler; pub use self::door::UseDoorHandler; pub use self::emote::FaceExpressionHandler; pub use self::fame::{GiveFameHandler, MIN_FAME_LEVEL}; -pub use self::guild::GuildOperationHandler; +pub use self::guild::{DenyGuildRequestHandler, GuildOperationHandler}; pub use self::item_move::ItemMoveHandler; pub use self::keybinds::ChangeKeybindsHandler; pub use self::logged_in::PlayerLoggedInHandler; diff --git a/net/src/packet/handle/world/player_interaction.rs b/net/src/packet/handle/world/player_interaction.rs index 993d0dddc..f4bf0e544 100644 --- a/net/src/packet/handle/world/player_interaction.rs +++ b/net/src/packet/handle/world/player_interaction.rs @@ -1,23 +1,38 @@ use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; -use crate::packet::build::world::mini_room::TRADE_ROOM; +use crate::packet::build::world::mini_room::{PERSONAL_SHOP, TRADE_ROOM}; +use crate::personal_shop::PersonalShopRequest; use crate::trade::TradeRequest; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use tracing::debug; -/// `PLAYER_INTERACTION`, for the trading room only: mode 0 creates a room of -/// a type, 2 invites, 3 declines, 4 accepts (visits), 6 chats, 0xa leaves, -/// 0xf puts up an item, 0x10 mesos, and 0x11 presses Trade. The runtime holds -/// the offers and the field the room. +/// `PLAYER_INTERACTION`, for trading rooms and personal shops. Mode 0 +/// creates a room of a type; 4 visits (accepting a trade invitation or +/// entering a shop), 6 chats and 0xa leaves either. For trades, 2 invites, 3 +/// declines, 0xf puts up an item, 0x10 mesos, and 0x11 presses Trade. For +/// shops, 0x16 lists an item, 0x1e sends the blacklist and 0x1f/0x20 change +/// it, 0xb opens the shop, 0x17 buys, 0x1b takes a listing back, and +/// 0x1c/0x1d ban a visitor. The runtime holds the goods and the field the +/// room. pub struct PlayerInteractionHandler; impl WorldHandler for PlayerInteractionHandler { fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { let mut reader = Cursor::new(&packet.bytes); reader.read_short()?; + let shop = |request| Ok(HandlerResult::empty().with(WorldAction::PersonalShop(request))); let request = match reader.read_byte()? { 0x00 => match reader.read_byte()? { TRADE_ROOM => TradeRequest::Create, + PERSONAL_SHOP => { + let title = reader.read_str_with_length()?; + reader.read_byte()?; + return shop(PersonalShopRequest::Create { + title, + permit_slot: reader.read_short()?, + permit_id: reader.read_int()?, + }); + } kind => { debug!(kind, "Ignoring mini room of another type"); return Ok(HandlerResult::empty()); @@ -45,6 +60,52 @@ impl WorldHandler for PlayerInteractionHandler { }, 0x10 => TradeRequest::Meso(reader.read_int()?), 0x11 => TradeRequest::Confirm, + 0x0b => return shop(PersonalShopRequest::Open), + 0x16 => { + return shop(PersonalShopRequest::PutItem { + kind: i16::from(reader.read_byte()?), + slot: reader.read_short()?, + bundles: reader.read_short()?, + per_bundle: reader.read_short()?, + price: reader.read_int()?, + }) + } + // The item's data checksum follows. + 0x17 => { + return shop(PersonalShopRequest::Buy { + index: reader.read_byte()?, + bundles: reader.read_short()?, + }) + } + 0x1b => { + return shop(PersonalShopRequest::TakeBack { + index: reader.read_short()?, + }) + } + mode @ (0x1c | 0x1d) => { + return shop(PersonalShopRequest::Ban { + slot: reader.read_byte()?, + name: reader.read_str_with_length()?, + overstayed: mode == 0x1d, + }) + } + 0x1e => { + let count = reader.read_short()?; + let names = (0..count) + .map(|_| reader.read_str_with_length()) + .collect::>()?; + return shop(PersonalShopRequest::Blacklist(names)); + } + 0x1f => { + return shop(PersonalShopRequest::BlacklistAdd( + reader.read_str_with_length()?, + )) + } + 0x20 => { + return shop(PersonalShopRequest::BlacklistRemove( + reader.read_str_with_length()?, + )) + } other => { // 0x14 answers the partner's Trade press with item CRCs. debug!(mode = other, "Ignoring player interaction"); @@ -62,6 +123,22 @@ mod tests { use packet::io::write::PktWrite; fn parse(build: impl FnOnce(&mut Packet) -> T) -> Option { + match action(build) { + Some(WorldAction::Trade(request)) => Some(request), + None => None, + Some(other) => panic!("unexpected {other:?}"), + } + } + + fn parse_shop(build: impl FnOnce(&mut Packet) -> T) -> Option { + match action(build) { + Some(WorldAction::PersonalShop(request)) => Some(request), + None => None, + Some(other) => panic!("unexpected {other:?}"), + } + } + + fn action(build: impl FnOnce(&mut Packet) -> T) -> Option { let mut packet = Packet::new_empty(); packet .write_short(RecvOpcode::PlayerInteraction as i16) @@ -76,11 +153,7 @@ mod tests { let result = PlayerInteractionHandler .handle(&mut packet, &mut ctx) .unwrap(); - match result.actions.into_iter().next() { - Some(WorldAction::Trade(request)) => Some(request), - None => None, - Some(other) => panic!("unexpected {other:?}"), - } + result.actions.into_iter().next() } #[test] @@ -147,9 +220,106 @@ mod tests { assert_eq!(confirm, Some(TradeRequest::Confirm)); } + #[test] + fn reads_the_shop_modes() { + let create = parse_shop(|p| { + p.write_bytes(&[0, 4]).unwrap(); + p.write_str_with_length("Pots!").unwrap(); + p.write_byte(0).unwrap(); + p.write_short(3).unwrap(); + p.write_int(5140000).unwrap(); + }); + assert_eq!( + create, + Some(PersonalShopRequest::Create { + title: "Pots!".into(), + permit_slot: 3, + permit_id: 5140000 + }) + ); + let put = parse_shop(|p| { + p.write_bytes(&[0x16, 2]).unwrap(); + p.write_short(4).unwrap(); + p.write_short(3).unwrap(); + p.write_short(10).unwrap(); + p.write_int(500).unwrap(); + }); + assert_eq!( + put, + Some(PersonalShopRequest::PutItem { + kind: 2, + slot: 4, + bundles: 3, + per_bundle: 10, + price: 500 + }) + ); + let blacklist = parse_shop(|p| { + p.write_byte(0x1e).unwrap(); + p.write_short(2).unwrap(); + p.write_str_with_length("Alice").unwrap(); + p.write_str_with_length("Bobby").unwrap(); + }); + assert_eq!( + blacklist, + Some(PersonalShopRequest::Blacklist(vec![ + "Alice".into(), + "Bobby".into() + ])) + ); + let add = parse_shop(|p| { + p.write_byte(0x1f).unwrap(); + p.write_str_with_length("Carol").unwrap(); + }); + assert_eq!(add, Some(PersonalShopRequest::BlacklistAdd("Carol".into()))); + let remove = parse_shop(|p| { + p.write_byte(0x20).unwrap(); + p.write_str_with_length("Carol").unwrap(); + }); + assert_eq!( + remove, + Some(PersonalShopRequest::BlacklistRemove("Carol".into())) + ); + assert_eq!( + parse_shop(|p| p.write_bytes(&[0x0b, 1])), + Some(PersonalShopRequest::Open) + ); + let buy = parse_shop(|p| { + p.write_bytes(&[0x17, 2]).unwrap(); + p.write_short(5).unwrap(); + p.write_int(0x1234).unwrap(); + }); + assert_eq!( + buy, + Some(PersonalShopRequest::Buy { + index: 2, + bundles: 5 + }) + ); + let take = parse_shop(|p| { + p.write_byte(0x1b).unwrap(); + p.write_short(1).unwrap(); + }); + assert_eq!(take, Some(PersonalShopRequest::TakeBack { index: 1 })); + for (mode, overstayed) in [(0x1c, false), (0x1d, true)] { + let ban = parse_shop(|p| { + p.write_bytes(&[mode, 2]).unwrap(); + p.write_str_with_length("Bobby").unwrap(); + }); + assert_eq!( + ban, + Some(PersonalShopRequest::Ban { + slot: 2, + name: "Bobby".into(), + overstayed + }) + ); + } + } + #[test] fn ignores_other_rooms_and_the_crc_answer() { - assert_eq!(parse(|p| p.write_bytes(&[0, 4, 0])), None); + assert_eq!(parse(|p| p.write_bytes(&[0, 1, 0])), None); assert_eq!(parse(|p| p.write_bytes(&[0x14, 0])), None); } } diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index daa414e54..e0b3a2f70 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -44,6 +44,7 @@ pub enum RecvOpcode { UseDoor = 0x85, DenyPartyRequest = 0x7D, GuildOperation = 0x7E, + DenyGuildRequest = 0x7F, FaceExpression = 0x33, Whisper = 0x78, Messenger = 0x7A, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 01c1a7094..bcd26c84c 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -49,6 +49,7 @@ pub enum SendOpcode { SpawnPlayer = 0xA0, RemovePlayerFromMap = 0xA1, ChatText = 0xA2, + UpdateCharBox = 0xA5, ShowScrollEffect = 0xA7, SpawnSummon = 0xAF, RemoveSummon = 0xB0, diff --git a/net/src/personal_shop.rs b/net/src/personal_shop.rs new file mode 100644 index 000000000..ba4e6c51b --- /dev/null +++ b/net/src/personal_shop.rs @@ -0,0 +1,466 @@ +//! Personal shops (PLAYER_INTERACTION, mini room type 4): a player with a +//! store permit opens a shop on a Free Market map and sells items in +//! bundles to up to three visitors at a time. The room lives in the field +//! and shows copies of the listings; the owner's client actor holds the +//! stock itself, out of their inventory, until it sells or the shop closes. +//! These are the rules for listing, buying and selling. + +use crate::packet::build::world::mini_room::{buy_result, SHOP_ITEMS}; +use crate::shop::is_rechargeable; +use crate::storage::storable; +use crate::trade::{self, Offer, TradeFailure, LOW_LEVEL_MAX, LOW_LEVEL_MESO_LIMIT}; +use game::{InventoryChange, InventoryType, Item, Player}; + +/// What the owner or a visitor asked of a personal shop. Visiting, +/// chatting and leaving are shared with the trading room +/// (`trade::TradeRequest`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PersonalShopRequest { + /// Open a shop named `title` with the permit in slot `permit_slot` of + /// the cash bag. + Create { + title: String, + permit_slot: i16, + permit_id: i32, + }, + /// List `bundles` bundles of `per_bundle` of the item in `slot` of bag + /// `kind`, at `price` a bundle. + PutItem { + kind: i16, + slot: i16, + bundles: i16, + per_bundle: i16, + price: i32, + }, + /// The owner's blacklist, sent just before the shop opens. + Blacklist(Vec), + BlacklistAdd(String), + BlacklistRemove(String), + /// Open the shop to visitors. + Open, + /// Buy `bundles` bundles of listing `index`. + Buy { + index: u8, + bundles: i16, + }, + /// The owner takes listing `index` back. + TakeBack { + index: i16, + }, + /// The owner turns the visitor in `slot` out; `overstayed` when their + /// client does it after an hour. + Ban { + slot: u8, + name: String, + overstayed: bool, + }, +} + +/// How many visitors a shop seats besides its owner. +pub const VISITORS: u8 = 3; + +/// Whether `item_id` is a store permit (514xxxx). +pub fn is_permit(item_id: i32) -> bool { + item_id / 10_000 == 514 +} + +/// The balloon a permit gives its shop (`ChatBalloon.img/miniroom/PSSkin`). +pub fn permit_skin(item_id: i32) -> u8 { + (item_id % 100) as u8 +} + +/// Whether players may open shops on `map_id` (Map.nx `info/personalShop`, +/// set in the Free Market rooms). +pub fn allowed_on(map_id: i32) -> bool { + crate::get_game_data() + .ok() + .and_then(|data| data.field(map_id)) + .is_some_and(|field| field.personal_shop) +} + +/// What a shop lists in one row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Listing { + /// One bundle. + pub item: Item, + /// Bundles left; 0 once sold out. + pub bundles: i16, + /// The price of a bundle. + pub price: i32, +} + +impl Listing { + /// `bundles` bundles as one item. + pub fn take(&self, bundles: i16) -> Item { + Item { + quantity: self.item.quantity * bundles, + ..self.item.clone() + } + } +} + +/// The tax on a sale of `gross`, which the owner pays: as the v83 client +/// works it out for "Total Received" (FUN_006fa066), the gross times a +/// double rate, truncated. (The x87 runs at the 53-bit precision Windows +/// sets by default, so doubles match.) +pub fn tax(gross: i32) -> i32 { + let rate = match gross { + 100_000_000.. => 0.03, + 25_000_000.. => 0.025, + 10_000_000.. => 0.02, + 5_000_000.. => 0.015, + 1_000_000.. => 0.009, + 100_000.. => 0.004, + _ => return 0, + }; + (f64::from(gross) * rate) as i32 +} + +/// Whether nothing is left to sell. +pub fn sold_out(listings: &[Listing]) -> bool { + listings.iter().all(|listing| listing.bundles == 0) +} + +/// Take the item in a bag slot out of the inventory to list it. Equipment +/// and sets of stars or bullets go whole, as one bundle. Returns the +/// listing and the inventory changes, or why the request is refused +/// (nothing changes then). +pub fn list_item( + player: &mut Player, + listings: &[Listing], + kind: i16, + slot: i16, + bundles: i16, + per_bundle: i16, + price: i32, +) -> Result<(Listing, Vec), &'static str> { + if listings.len() >= usize::from(SHOP_ITEMS) { + return Err("the shop is full"); + } + let kind = InventoryType::from_i16(kind) + .filter(|&kind| kind != InventoryType::Equipped) + .ok_or("not a bag")?; + let item = player + .inventory + .get(kind, slot) + .filter(|_| slot > 0) + .ok_or("no item in that slot")?; + if !storable(item) { + return Err("item can't be sold"); + } + let (bundles, per_bundle) = if is_rechargeable(item.item_id) || item.equip.is_some() { + (1, item.quantity) + } else { + (bundles, per_bundle) + }; + let count = i32::from(bundles) * i32::from(per_bundle); + if bundles < 1 || per_bundle < 1 || count > i32::from(item.quantity) { + return Err("bad quantity"); + } + if price < 1 || i64::from(price) * i64::from(bundles) > i64::from(i32::MAX) { + return Err("bad price"); + } + let listing = Listing { + item: Item { + quantity: per_bundle, + ..item.clone() + }, + bundles, + price, + }; + let changes = player + .inventory_mut() + .take(kind, slot, count as i16) + .map_err(|_| "bad quantity")?; + Ok((listing, changes)) +} + +/// What `bundles` of `listing` cost `buyer`, or the `buy_result` that says +/// why they can't have them. +pub fn check_purchase(buyer: &Player, listing: &Listing, bundles: i16) -> Result { + if bundles < 1 || bundles > listing.bundles { + return Err(buy_result::NO_STOCK); + } + let total = i32::try_from(i64::from(listing.price) * i64::from(bundles)) + .map_err(|_| buy_result::TOO_PRICEY)?; + if total > buyer.meso { + return Err(buy_result::NO_MESOS); + } + if buyer.stats.level <= LOW_LEVEL_MAX && total > LOW_LEVEL_MESO_LIMIT { + return Err(buy_result::LOW_LEVEL_LIMIT); + } + trade::check(buyer, &goods(vec![listing.take(bundles)])).map_err(|failure| match failure { + TradeFailure::NoRoom => buy_result::INVENTORY_FULL, + TradeFailure::OnlyOne => buy_result::ONLY_ONE, + })?; + Ok(total) +} + +/// Whether the owner can sell `bundles` of listing `index`, still the item +/// `item_id`, for `paid`: in stock at that price, and the proceeds after +/// tax fit in their purse. Returns the `buy_result` for the buyer if not. +pub fn check_sale( + owner: &Player, + listings: &[Listing], + index: usize, + item_id: i32, + bundles: i16, + paid: i32, +) -> Result<(), u8> { + let listing = listings + .get(index) + .filter(|listing| listing.item.item_id == item_id) + .filter(|listing| (1..=listing.bundles).contains(&bundles)) + .ok_or(buy_result::NO_STOCK)?; + if i64::from(listing.price) * i64::from(bundles) != i64::from(paid) { + return Err(buy_result::NO_STOCK); + } + if i64::from(owner.meso) + i64::from(paid - tax(paid)) > i64::from(i32::MAX) { + return Err(buy_result::TOO_PRICEY); + } + Ok(()) +} + +/// Sell what `check_sale` allowed: the bundles leave the listing and the +/// owner gets `paid` less tax. Returns the items for the buyer. +pub fn sell( + owner: &mut Player, + listings: &mut [Listing], + index: usize, + bundles: i16, + paid: i32, +) -> Item { + let listing = &mut listings[index]; + listing.bundles -= bundles; + owner.meso += paid - tax(paid); + owner.mark_dirty(); + listing.take(bundles) +} + +/// The items of `listings` still unsold, to give back to the owner. +pub fn unsold(listings: &[Listing]) -> Offer { + goods( + listings + .iter() + .filter(|listing| listing.bundles > 0) + .map(|listing| listing.take(listing.bundles)) + .collect(), + ) +} + +/// Items as an offer without mesos, for `trade::check` and `trade::give`. +pub fn goods(items: Vec) -> Offer { + Offer { + items: (1..).zip(items).collect(), + meso: 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::items; + + const RED_POTION: i32 = 2000000; + const SUBI: i32 = 2070000; + const SWORD: i32 = 1302000; + + fn player(meso: i32, level: u8) -> Player { + let mut player = crate::codec::stats::sample_player(); + player.meso = meso; + player.stats.level = level; + player + } + + fn potions(bundles: i16, per_bundle: i16, price: i32) -> Listing { + Listing { + item: Item::stack(RED_POTION, per_bundle), + bundles, + price, + } + } + + #[test] + fn tax_follows_the_client_table_in_doubles() { + assert_eq!(tax(0), 0); + assert_eq!(tax(99_999), 0); + assert_eq!(tax(100_000), 400); + assert_eq!(tax(1_000_000), 9_000); + assert_eq!(tax(1_234_567), 11_111); + assert_eq!(tax(5_000_000), 75_000); + assert_eq!(tax(10_000_000), 200_000); + assert_eq!(tax(25_000_000), 625_000); + assert_eq!(tax(100_000_000), 3_000_000); + assert_eq!(tax(i32::MAX), 64_424_509); + } + + #[test] + fn permits_pick_the_balloon() { + assert!(is_permit(5140000) && is_permit(5140006)); + assert!(!is_permit(5150000) && !is_permit(2000000)); + assert_eq!((permit_skin(5140000), permit_skin(5140006)), (0, 6)); + } + + #[test] + fn listing_takes_whole_bundles_from_the_bag() { + if items::data().is_err() { + return; + } + let mut player = player(0, 30); + items::give(player.inventory_mut(), RED_POTION, 30).unwrap(); + let (listing, changes) = list_item(&mut player, &[], 2, 1, 4, 5, 100).unwrap(); + assert_eq!(listing, potions(4, 5, 100)); + assert_eq!( + changes, + vec![InventoryChange::Quantity { + kind: InventoryType::Use, + slot: 1, + quantity: 10 + }] + ); + assert_eq!(player.inventory.count(RED_POTION), 10); + } + + #[test] + fn stars_and_equipment_list_whole() { + if items::data().is_err() { + return; + } + let mut player = player(0, 30); + items::give(player.inventory_mut(), SUBI, 500).unwrap(); + items::give(player.inventory_mut(), SWORD, 1).unwrap(); + let (stars, _) = list_item(&mut player, &[], 2, 1, 7, 3, 9_000).unwrap(); + assert_eq!((stars.bundles, stars.item.quantity), (1, 500)); + let (sword, _) = list_item(&mut player, &[], 1, 1, 1, 1, 50_000).unwrap(); + assert!(sword.item.equip.is_some()); + assert_eq!(sword.bundles, 1); + assert_eq!( + player.inventory.count(SUBI) + player.inventory.count(SWORD), + 0 + ); + } + + #[test] + fn bad_listings_change_nothing() { + if items::data().is_err() { + return; + } + let mut player = player(0, 30); + items::give(player.inventory_mut(), RED_POTION, 10).unwrap(); + player + .inventory + .insert(InventoryType::Cash, 1, Item::stack(5140000, 1)); + let before = player.inventory.clone(); + let full = vec![potions(1, 1, 1); 16]; + for (listings, kind, slot, bundles, per_bundle, price) in [ + (&full[..], 2, 1, 1, 1, 1), // sixteen listed + (&[][..], 2, 1, 3, 4, 1), // more than there is + (&[][..], 2, 1, 0, 1, 1), // no bundles + (&[][..], 2, 1, 1, 0, 1), // empty bundles + (&[][..], 2, 1, 1, 1, 0), // free + (&[][..], 2, 1, 2, 1, i32::MAX), // more than a purse holds + (&[][..], 2, 2, 1, 1, 1), // empty slot + (&[][..], -1, -11, 1, 1, 1), // equipped + (&[][..], 5, 1, 1, 1, 1), // cash item + ] { + assert!( + list_item( + &mut player, + listings, + kind, + slot, + bundles, + per_bundle, + price + ) + .is_err(), + "{kind} {slot} {bundles} {per_bundle} {price}" + ); + } + assert_eq!(player.inventory, before); + } + + #[test] + fn buyers_need_stock_mesos_and_room() { + if items::data().is_err() { + return; + } + let listing = potions(3, 10, 400_000); + let buyer = player(1_000_000, 30); + assert_eq!(check_purchase(&buyer, &listing, 2), Ok(800_000)); + assert_eq!( + check_purchase(&buyer, &listing, 0), + Err(buy_result::NO_STOCK) + ); + assert_eq!( + check_purchase(&buyer, &listing, 4), + Err(buy_result::NO_STOCK) + ); + assert_eq!( + check_purchase(&buyer, &listing, 3), + Err(buy_result::NO_MESOS) + ); + let pricey = potions(3, 10, i32::MAX); + assert_eq!( + check_purchase(&player(i32::MAX, 30), &pricey, 2), + Err(buy_result::TOO_PRICEY) + ); + // Up to level 15, a million a purchase. + let low = player(2_000_000, 15); + assert_eq!( + check_purchase(&low, &potions(3, 10, 500_000), 2), + Ok(1_000_000) + ); + assert_eq!( + check_purchase(&low, &listing, 3), + Err(buy_result::LOW_LEVEL_LIMIT) + ); + + let mut full = player(1_000_000, 30); + full.inventory.set_slot_limit(InventoryType::Use, 1); + items::give(full.inventory_mut(), 2000001, 1).unwrap(); + assert_eq!( + check_purchase(&full, &listing, 1), + Err(buy_result::INVENTORY_FULL) + ); + } + + #[test] + fn a_sale_moves_bundles_and_pays_the_owner_less_tax() { + let mut owner = player(1_000, 30); + let mut listings = vec![potions(3, 10, 400_000), potions(1, 5, 10)]; + assert_eq!( + check_sale(&owner, &listings, 0, RED_POTION, 2, 800_000), + Ok(()) + ); + for (index, item_id, bundles, paid) in [ + (2, RED_POTION, 1, 400_000), // no such listing + (0, SUBI, 1, 400_000), // another item now + (0, RED_POTION, 4, 1_600_000), // more than is left + (0, RED_POTION, 1, 300_000), // another price now + ] { + assert_eq!( + check_sale(&owner, &listings, index, item_id, bundles, paid), + Err(buy_result::NO_STOCK) + ); + } + let sold = sell(&mut owner, &mut listings, 0, 2, 800_000); + assert_eq!(sold, Item::stack(RED_POTION, 20)); + assert_eq!(listings[0].bundles, 1); + assert_eq!(owner.meso, 1_000 + 800_000 - 3_200); + assert!(!sold_out(&listings)); + + sell(&mut owner, &mut listings, 0, 1, 400_000); + assert_eq!(unsold(&listings), goods(vec![Item::stack(RED_POTION, 5)])); + sell(&mut owner, &mut listings, 1, 1, 10); + assert!(sold_out(&listings)); + assert_eq!(unsold(&listings), goods(Vec::new())); + + owner.meso = i32::MAX - 100; + let listings = vec![potions(1, 1, 1_000)]; + assert_eq!( + check_sale(&owner, &listings, 0, RED_POTION, 1, 1_000), + Err(buy_result::TOO_PRICEY) + ); + } +} diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index c21d2796c..4de338f58 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -7,6 +7,7 @@ mod fame; mod guilds; mod loot; mod npc_script; +mod personal_shop; mod quest; mod script; mod ships; @@ -118,6 +119,8 @@ pub struct ClientActor { door: Option, /// The player's side of a trade they are in. trade: Option, + /// The player's own shop, or the one they visit. + personal_shop: Option, /// Heracle asked for a new guild's name; only then is one founded. naming_guild: bool, } @@ -172,6 +175,7 @@ impl ClientActor { presence: None, door: None, trade: None, + personal_shop: None, naming_guild: false, }) } @@ -350,9 +354,14 @@ impl ClientActor { WorldAction::Quest(request) => self.request_quest_action(request).await?, WorldAction::Shop(request) => self.shop_action(request).await?, WorldAction::Storage(request) => self.storage_request(request).await?, - WorldAction::Trade(request) => { + WorldAction::Trade(_) if self.in_cash_shop => {} + WorldAction::Trade(request) if self.personal_shop.is_some() => { + self.shop_room_request(request).await? + } + WorldAction::Trade(request) => self.trade_request(request).await?, + WorldAction::PersonalShop(request) => { if !self.in_cash_shop { - self.trade_request(request).await? + self.personal_shop_request(request).await? } } WorldAction::ScriptPortal { portal } => self.touch_script_portal(&portal).await?, @@ -518,6 +527,7 @@ impl ClientActor { self.in_game_mut()?.player.chair = 0; self.leave_trade().await?; + self.leave_personal_shop().await?; self.send_to_field(FieldMessage::Leave { client_id: self.client_id, }) @@ -561,6 +571,7 @@ impl ClientActor { self.cancel_buffs().await?; self.close_door().await?; self.leave_trade().await?; + self.leave_personal_shop().await?; self.flush().await?; spawn_db(move || db::session::begin_channel_transition(session_id, i16::from(channel_id))) .await?; @@ -584,6 +595,7 @@ impl ClientActor { self.naming_guild = false; self.npc_request_id = self.npc_request_id.wrapping_add(1); self.leave_trade().await?; + self.leave_personal_shop().await?; self.send_to_field(FieldMessage::Leave { client_id: self.client_id, }) @@ -971,6 +983,8 @@ impl ClientActor { }) .await? } + ServerMessage::JoinGuild { guild_id } => self.join_guild(guild_id).await?, + ServerMessage::GuildChanged => self.guild_changed().await?, ServerMessage::PartySkill { used, origin, @@ -1012,6 +1026,7 @@ impl ClientActor { portal, } => self.ship_warp(from_map, map_id, portal).await?, ServerMessage::Trade { room, event } => self.trade_event(room, event).await?, + ServerMessage::PersonalShop { room, event } => self.shop_event(room, event).await?, ServerMessage::SendPacket(mut packet) => self.send(&mut packet).await?, } Ok(()) @@ -1061,6 +1076,7 @@ impl ClientActor { FieldMessage::Join { .. } | FieldMessage::Leave { .. } | FieldMessage::Trade { .. } + | FieldMessage::PersonalShop { .. } ) { field .tx @@ -1068,8 +1084,9 @@ impl ClientActor { .await .map_err(|_| RuntimeError::ChannelSend)?; } - // The trading room died with the field. - self.abandon_trade().await + // The trading room or shop died with the field. + self.abandon_trade().await?; + self.abandon_personal_shop().await } } } @@ -1120,10 +1137,13 @@ impl ClientActor { return Ok(()); } let mut snapshot = game.player.clone(); - // What the player put up in a trade is still theirs. + // What the player put up in a trade or shop is still theirs. if let Some(trade) = &self.trade { trade.add_back(&mut snapshot); } + if let Some(shop) = &self.personal_shop { + shop.add_back(&mut snapshot); + } spawn_db(move || db::player::save_player(&snapshot)).await?; game.player.mark_saved(); Ok(()) @@ -1153,10 +1173,13 @@ impl ClientActor { async fn cleanup(&mut self) { if self.game.is_some() { // Before the save: the player gets their offer back, or the - // partner's. + // partner's, and their unsold stock. if let Err(error) = self.leave_trade().await { warn!(self.client_id, %error, "Failed to finish trade"); } + if let Err(error) = self.leave_personal_shop().await { + warn!(self.client_id, %error, "Failed to close personal shop"); + } if !self.in_cash_shop { let _ = self .send_to_field(FieldMessage::Leave { diff --git a/runtime/src/actor/client/guilds.rs b/runtime/src/actor/client/guilds.rs index ecca34ab5..8aa800593 100644 --- a/runtime/src/actor/client/guilds.rs +++ b/runtime/src/actor/client/guilds.rs @@ -4,11 +4,12 @@ use crate::{ error::RuntimeError, message::{DirectoryMessage, FieldMessage}, }; -use db::guild::CreateError; -use game::guild; +use db::guild::{CreateError, JoinError}; +use game::guild::{self, Emblem, GuildMember, GuildTag}; use net::packet::build::world::guild::{ build_guild_chat, build_guild_created, build_guild_error, build_guild_info, - build_guild_mark_changed, build_guild_name_changed, GuildError, + build_guild_mark_changed, build_guild_name_changed, build_member_expelled, build_member_joined, + build_member_left, GuildError, }; use net::packet::build::world::stat::StatUpdate; use net::packet::build::world::{drop, stat}; @@ -52,7 +53,162 @@ impl ClientActor { self.send_to_directory(DirectoryMessage::Deliver { to, packet }) .await } + GuildRequest::Invite { name } => match guild_id { + Some(guild_id) => self.invite_to_guild(guild_id, name).await, + None => Ok(()), + }, + GuildRequest::Accept { guild_id } => { + self.send_to_directory(DirectoryMessage::GuildAccept { + from: self.client_id, + guild_id, + }) + .await + } + GuildRequest::Deny { inviter, refusal } => { + self.send_to_directory(DirectoryMessage::GuildDeny { + from: self.client_id, + inviter, + refusal, + }) + .await + } + GuildRequest::Leave => match guild_id { + Some(guild_id) => self.leave_guild(guild_id).await, + None => Ok(()), + }, + GuildRequest::Expel { member_id } => match guild_id { + Some(guild_id) => self.expel_from_guild(guild_id, member_id).await, + None => Ok(()), + }, + } + } + + /// The client checks the inviter's rank and the guild's room first. + async fn invite_to_guild(&mut self, guild_id: i32, name: String) -> Result<(), RuntimeError> { + let guild = spawn_db(move || db::guild::load(guild_id)).await?; + if !guild.may_invite(self.client_id) { + warn!( + self.client_id, + "Guild invitation the client should not send; ignored" + ); + return Ok(()); + } + self.send_to_directory(DirectoryMessage::GuildInvite { + from: self.client_id, + guild_id, + name, + }) + .await + } + + /// The directory confirmed the invitation: join at the lowest rank. + /// Every member online hears it; the newcomer's client then asks for the + /// whole guild. + pub(super) async fn join_guild(&mut self, guild_id: i32) -> Result<(), RuntimeError> { + let cid = self.client_id; + let refusal = match spawn_db(move || db::guild::add_member(guild_id, cid)).await? { + Ok(()) => None, + Err(JoinError::Full) => Some(GuildError::Full), + Err(JoinError::AlreadyInGuild) => Some(GuildError::InviteeInGuild), + }; + if let Some(error) = refusal { + return self.send(&mut build_guild_error(error)?).await; + } + let guild = spawn_db(move || db::guild::load(guild_id)).await?; + let stats = &self.in_game()?.player.stats; + let member = GuildMember { + id: cid, + name: stats.name.clone(), + job: stats.job, + level: stats.level, + rank: guild::MEMBER_RANK, + online: true, + }; + self.send_to_directory(DirectoryMessage::Deliver { + to: guild.members.iter().map(|member| member.id).collect(), + packet: build_member_joined(guild_id, &member)?, + }) + .await?; + self.set_guild(Some(guild.tag())).await + } + + /// The master can't leave; the client doesn't offer it. + async fn leave_guild(&mut self, guild_id: i32) -> Result<(), RuntimeError> { + let cid = self.client_id; + if !spawn_db(move || db::guild::remove_member(guild_id, cid)).await? { + warn!(cid, "The guild master can't leave the guild; ignored"); + return Ok(()); + } + let mut to = spawn_db(move || db::guild::member_ids(guild_id)).await?; + to.push(cid); + let name = self.in_game()?.player.stats.name.clone(); + self.send_to_directory(DirectoryMessage::Deliver { + to, + packet: build_member_left(guild_id, cid, &name)?, + }) + .await?; + self.set_guild(None).await + } + + /// Put a member out, if the player's rank allows it (`Guild::may_expel`). + async fn expel_from_guild( + &mut self, + guild_id: i32, + member_id: i32, + ) -> Result<(), RuntimeError> { + let guild = spawn_db(move || db::guild::load(guild_id)).await?; + let name = match guild.member(member_id) { + Some(member) if guild.may_expel(self.client_id, member_id) => member.name.clone(), + _ => { + warn!( + self.client_id, + member_id, "Guild expulsion not allowed; ignored" + ); + return Ok(()); + } + }; + if !spawn_db(move || db::guild::remove_member(guild_id, member_id)).await? { + return Ok(()); } + self.send_to_directory(DirectoryMessage::Deliver { + to: guild.members.iter().map(|member| member.id).collect(), + packet: build_member_expelled(guild_id, member_id, &name)?, + }) + .await?; + self.send_to_directory(DirectoryMessage::GuildChanged { + client_id: member_id, + }) + .await + } + + /// Someone else changed the player's guild: read it again. + pub(super) async fn guild_changed(&mut self) -> Result<(), RuntimeError> { + let cid = self.client_id; + let tag = spawn_db(move || db::guild::tag(cid)).await?; + self.set_guild(tag).await + } + + /// Others on the map see the guild's name and emblem under the player's, + /// or none. + async fn set_guild(&mut self, tag: Option) -> Result<(), RuntimeError> { + let cid = self.client_id; + let (name, emblem) = tag + .as_ref() + .map_or((String::new(), Emblem::default()), |tag| { + (tag.name.clone(), tag.emblem) + }); + let player = &mut self.in_game_mut()?.player; + player.guild = tag; + let avatar = player.field_avatar(0, 0, 0); + self.send_to_field(FieldMessage::UpdateAvatar { + client_id: cid, + avatar, + announce: vec![ + build_guild_name_changed(cid, &name)?, + build_guild_mark_changed(cid, &emblem)?, + ], + }) + .await } /// The directory, which knows who is online, sends the guild. @@ -122,9 +278,8 @@ impl ClientActor { } let player = &mut self.in_game_mut()?.player; player.meso -= guild::CREATE_COST; - player.guild = Some(guild.tag()); player.mark_dirty(); - let (meso, avatar) = (player.meso, player.field_avatar(0, 0, 0)); + let meso = player.meso; self.flush().await?; let mut packets = vec![ stat::build_stat_update(false, &[StatUpdate::Meso(meso)])?, @@ -132,15 +287,6 @@ impl ClientActor { build_guild_created(&guild)?, ]; self.send_all(&mut packets).await?; - // Others on the map see the name and emblem under the master's. - self.send_to_field(FieldMessage::UpdateAvatar { - client_id: cid, - avatar, - announce: vec![ - build_guild_name_changed(cid, &guild.name)?, - build_guild_mark_changed(cid, &guild.emblem)?, - ], - }) - .await + self.set_guild(Some(guild.tag())).await } } diff --git a/runtime/src/actor/client/personal_shop.rs b/runtime/src/actor/client/personal_shop.rs new file mode 100644 index 000000000..1c9980872 --- /dev/null +++ b/runtime/src/actor/client/personal_shop.rs @@ -0,0 +1,430 @@ +use super::trade::NEXT_ROOM; +use super::ClientActor; +use crate::actor::field_shops::{Sale, ShopEvent, ShopRoomRequest}; +use crate::error::RuntimeError; +use crate::message::FieldMessage; +use game::{InventoryType, Player}; +use net::packet::build::world::mini_room::{ + build_buy_result, build_enter_error, build_room_leave, build_shop_sold, buy_result, + enter_error, leave_reason, +}; +use net::packet::build::world::stat::StatUpdate; +use net::packet::build::world::{inventory, stat}; +use net::personal_shop::{self as rules, Listing, PersonalShopRequest}; +use net::trade::{self, TradeRequest}; +use packet::Packet; +use std::sync::atomic::Ordering; +use std::time::Duration; +use tokio::sync::oneshot; +use tracing::{debug, info}; + +/// How long a buyer, already charged, waits for the owner to hand over the +/// items before taking the mesos back. +const SALE_TIMEOUT: Duration = Duration::from_secs(5); + +/// The player's place in a personal shop. +pub(super) enum PersonalShop { + /// The player's own shop. The stock is out of their inventory until it + /// sells or the shop closes. + Owner { + room: i32, + listings: Vec, + open: bool, + }, + Visitor { + room: i32, + slot: u8, + }, +} + +impl PersonalShop { + fn room(&self) -> i32 { + match self { + Self::Owner { room, .. } | Self::Visitor { room, .. } => *room, + } + } + + /// Put the unsold stock back into a copy of the owner, so a save while + /// the shop is open keeps it. + pub(super) fn add_back(&self, snapshot: &mut Player) { + if let Self::Owner { listings, .. } = self { + trade::give(snapshot, rules::unsold(listings)); + } + } +} + +impl ClientActor { + pub(super) async fn personal_shop_request( + &mut self, + request: PersonalShopRequest, + ) -> Result<(), RuntimeError> { + match request { + PersonalShopRequest::Create { + title, + permit_slot, + permit_id, + } => self.create_shop(title, permit_slot, permit_id).await, + PersonalShopRequest::PutItem { + kind, + slot, + bundles, + per_bundle, + price, + } => self.list_item(kind, slot, bundles, per_bundle, price).await, + PersonalShopRequest::TakeBack { index } => self.take_back(index).await, + PersonalShopRequest::Open => { + let Some(PersonalShop::Owner { listings, open, .. }) = &mut self.personal_shop + else { + return Ok(()); + }; + if *open || listings.is_empty() { + return Ok(()); + } + *open = true; + self.to_shop(ShopRoomRequest::Open).await + } + PersonalShopRequest::Buy { index, bundles } => self.buy(index, bundles).await, + PersonalShopRequest::Ban { + slot, + name, + overstayed, + } => { + self.owner_to_shop(ShopRoomRequest::Ban { + slot, + name, + overstayed, + }) + .await + } + PersonalShopRequest::Blacklist(names) => { + self.owner_to_shop(ShopRoomRequest::Blacklist(names)).await + } + PersonalShopRequest::BlacklistAdd(name) => { + self.owner_to_shop(ShopRoomRequest::BlacklistAdd(name)) + .await + } + PersonalShopRequest::BlacklistRemove(name) => { + self.owner_to_shop(ShopRoomRequest::BlacklistRemove(name)) + .await + } + } + } + + /// Chatting and leaving, shared with the trading room, from a player in + /// a shop. + pub(super) async fn shop_room_request( + &mut self, + request: TradeRequest, + ) -> Result<(), RuntimeError> { + match request { + TradeRequest::Chat { text } => self.to_shop(ShopRoomRequest::Chat { text }).await, + TradeRequest::Leave => self.leave_personal_shop().await, + _ => Ok(()), + } + } + + /// Open a shop with the store permit in the cash bag, on a Free Market + /// map. The permit stays; the shop is empty and closed to visitors + /// until the owner lists items and opens it. + async fn create_shop( + &mut self, + title: String, + permit_slot: i16, + permit_id: i32, + ) -> Result<(), RuntimeError> { + if self.trade.is_some() || self.personal_shop.is_some() { + return Ok(()); + } + let player = &self.in_game()?.player; + let has_permit = player + .inventory + .get(InventoryType::Cash, permit_slot) + .is_some_and(|item| item.item_id == permit_id && rules::is_permit(permit_id)); + if !has_permit { + return Ok(()); + } + if !rules::allowed_on(player.stats.map_id) { + return self + .send(&mut build_enter_error(enter_error::NOT_FREE_MARKET)?) + .await; + } + let room = NEXT_ROOM.fetch_add(1, Ordering::Relaxed); + self.personal_shop = Some(PersonalShop::Owner { + room, + listings: Vec::new(), + open: false, + }); + self.to_shop(ShopRoomRequest::Create { + room, + title, + skin: rules::permit_skin(permit_id), + }) + .await + } + + /// Move an item from the inventory into the shop, before it opens. + async fn list_item( + &mut self, + kind: i16, + slot: i16, + bundles: i16, + per_bundle: i16, + price: i32, + ) -> Result<(), RuntimeError> { + let player = &mut self.game.as_mut().ok_or(RuntimeError::NotLoggedIn)?.player; + let Some(PersonalShop::Owner { + listings, + open: false, + .. + }) = &mut self.personal_shop + else { + return self.unlock().await; + }; + match rules::list_item(player, listings, kind, slot, bundles, per_bundle, price) { + Ok((listing, changes)) => { + listings.push(listing); + let listings = listings.clone(); + self.send(&mut inventory::build_inventory_operation(true, &changes)?) + .await?; + self.to_shop(ShopRoomRequest::Listings(listings)).await + } + Err(why) => { + debug!( + self.client_id, + kind, slot, bundles, per_bundle, price, why, "Shop listing refused" + ); + self.unlock().await + } + } + } + + /// Take a listing back into the inventory, before the shop opens. + async fn take_back(&mut self, index: i16) -> Result<(), RuntimeError> { + let player = &self.game.as_ref().ok_or(RuntimeError::NotLoggedIn)?.player; + let Some(PersonalShop::Owner { + listings, + open: false, + .. + }) = &mut self.personal_shop + else { + return self.unlock().await; + }; + let Some(index) = usize::try_from(index) + .ok() + .filter(|&i| listings.get(i).is_some_and(|l| l.bundles > 0)) + else { + return self.unlock().await; + }; + let items = rules::unsold(&listings[index..=index]); + if trade::check(player, &items).is_err() { + self.send(&mut inventory::build_inventory_full()?).await?; + return self.unlock().await; + } + listings.remove(index); + let listings = listings.clone(); + let changes = self.receive(items).await?; + self.send(&mut inventory::build_inventory_operation(true, &changes)?) + .await?; + self.to_shop(ShopRoomRequest::Listings(listings)).await + } + + /// Buy from the shop the player visits: charge them, ask the owner for + /// the items, and refund them if the owner can't hand them over. + async fn buy(&mut self, index: u8, bundles: i16) -> Result<(), RuntimeError> { + if !matches!(self.personal_shop, Some(PersonalShop::Visitor { .. })) { + return self.unlock().await; + } + let (reply, quote) = oneshot::channel(); + self.to_shop(ShopRoomRequest::Quote { index, reply }) + .await?; + let Ok(Some(listing)) = quote.await else { + return self.refuse_purchase(buy_result::NO_STOCK).await; + }; + let player = &mut self.in_game_mut()?.player; + let paid = match rules::check_purchase(player, &listing, bundles) { + Ok(paid) => paid, + Err(result) => return self.refuse_purchase(result).await, + }; + player.meso -= paid; + player.mark_dirty(); + let (reply, sold) = oneshot::channel(); + let sale = Sale { + index, + bundles, + item_id: listing.item.item_id, + paid, + buyer: player.stats.name.clone(), + reply, + }; + // Whatever happens to the request, the answer says whether to + // refund. + let sent = self.to_shop(ShopRoomRequest::Purchase(sale)).await; + let answer = tokio::time::timeout(SALE_TIMEOUT, sold).await; + let result = match answer { + Ok(Ok(Ok(item))) => { + let changes = self.receive(rules::goods(vec![item])).await?; + let meso = self.in_game()?.player.meso; + self.send(&mut inventory::build_inventory_operation(false, &changes)?) + .await?; + self.send(&mut stat::build_stat_update( + true, + &[StatUpdate::Meso(meso)], + )?) + .await + } + Ok(Ok(Err(result))) => self.refund(paid, result).await, + // The shop or its owner is gone, or too slow. + Ok(Err(_)) | Err(_) => self.refund(paid, buy_result::NO_STOCK).await, + }; + sent.and(result) + } + + async fn refund(&mut self, paid: i32, result: u8) -> Result<(), RuntimeError> { + let player = &mut self.in_game_mut()?.player; + player.meso += paid; + player.mark_dirty(); + self.refuse_purchase(result).await + } + + async fn refuse_purchase(&mut self, result: u8) -> Result<(), RuntimeError> { + self.send(&mut build_buy_result(result)?).await?; + self.unlock().await + } + + /// News from the player's shop. + pub(super) async fn shop_event( + &mut self, + room: i32, + event: ShopEvent, + ) -> Result<(), RuntimeError> { + if let ShopEvent::Entered { slot } = event { + if self.personal_shop.is_none() && self.trade.is_none() { + self.personal_shop = Some(PersonalShop::Visitor { room, slot }); + } + return Ok(()); + } + if self.personal_shop.as_ref().map(PersonalShop::room) != Some(room) { + // A sale dropped here refunds the buyer. + return Ok(()); + } + match event { + ShopEvent::Entered { .. } => Ok(()), + ShopEvent::Sale(sale) => self.sell(sale).await, + ShopEvent::Left(packet) => self.close_shop(packet).await, + } + } + + /// The owner sells to a visitor who has paid, if the stock is still + /// there and the buyer still waiting. + async fn sell(&mut self, sale: Sale) -> Result<(), RuntimeError> { + let player = &mut self.game.as_mut().ok_or(RuntimeError::NotLoggedIn)?.player; + let Some(PersonalShop::Owner { listings, .. }) = &mut self.personal_shop else { + return Ok(()); + }; + let index = usize::from(sale.index); + if let Err(result) = rules::check_sale( + player, + listings, + index, + sale.item_id, + sale.bundles, + sale.paid, + ) { + let _ = sale.reply.send(Err(result)); + return Ok(()); + } + if sale + .reply + .send(Ok(listings[index].take(sale.bundles))) + .is_err() + { + // The buyer gave up and took their mesos back. + return Ok(()); + } + rules::sell(player, listings, index, sale.bundles, sale.paid); + let meso = player.meso; + let listings = listings.clone(); + info!( + self.client_id, + buyer = sale.buyer, + item_id = sale.item_id, + bundles = sale.bundles, + paid = sale.paid, + "Shop sale" + ); + self.send(&mut build_shop_sold(sale.index, sale.bundles, &sale.buyer)?) + .await?; + self.send(&mut stat::build_stat_update( + false, + &[StatUpdate::Meso(meso)], + )?) + .await?; + if rules::sold_out(&listings) { + // Nothing is left to give back; the room closes the shop for + // everyone. + self.personal_shop = None; + return self.to_shop(ShopRoomRequest::SoldOut).await; + } + self.to_shop(ShopRoomRequest::Listings(listings)).await + } + + /// Leave the shop, if the player is in one. An owner closes it and gets + /// the unsold stock back. Every way off the field comes through here + /// first. + pub(super) async fn leave_personal_shop(&mut self) -> Result<(), RuntimeError> { + if self.personal_shop.is_none() { + return Ok(()); + } + let left = self.to_shop(ShopRoomRequest::Leave).await; + let closed = self.close_shop(None).await; + left.and(closed) + } + + /// The player's field died with their shop. + pub(super) async fn abandon_personal_shop(&mut self) -> Result<(), RuntimeError> { + let Some(shop) = &self.personal_shop else { + return Ok(()); + }; + let slot = match shop { + PersonalShop::Owner { .. } => 0, + PersonalShop::Visitor { slot, .. } => *slot, + }; + let leave = build_room_leave(slot, leave_reason::SHOP_CLOSED)?; + self.close_shop(Some(leave)).await + } + + /// The player is out of the shop: an owner gets the unsold stock back, + /// and whatever no longer fits drops at their feet. + async fn close_shop(&mut self, packet: Option) -> Result<(), RuntimeError> { + let Some(shop) = self.personal_shop.take() else { + return Ok(()); + }; + info!(self.client_id, room = shop.room(), "Left personal shop"); + if let PersonalShop::Owner { listings, .. } = shop { + let changes = self.receive(rules::unsold(&listings)).await?; + if !changes.is_empty() { + self.send(&mut inventory::build_inventory_operation(false, &changes)?) + .await?; + } + } + if let Some(mut packet) = packet { + self.send(&mut packet).await?; + } + Ok(()) + } + + async fn owner_to_shop(&mut self, request: ShopRoomRequest) -> Result<(), RuntimeError> { + if !matches!(self.personal_shop, Some(PersonalShop::Owner { .. })) { + return Ok(()); + } + self.to_shop(request).await + } + + async fn to_shop(&mut self, request: ShopRoomRequest) -> Result<(), RuntimeError> { + self.send_to_field(FieldMessage::PersonalShop { + from: self.client_id, + request, + }) + .await + } +} diff --git a/runtime/src/actor/client/trade.rs b/runtime/src/actor/client/trade.rs index 3e9fed278..af27a1466 100644 --- a/runtime/src/actor/client/trade.rs +++ b/runtime/src/actor/client/trade.rs @@ -2,7 +2,7 @@ use super::ClientActor; use crate::actor::field_trades::{RoomRequest, TradeEvent}; use crate::error::RuntimeError; use crate::message::FieldMessage; -use game::Player; +use game::{InventoryChange, Player}; use net::packet::build::world::mini_room::{ build_room_leave, build_trade_meso_limit, leave_reason, }; @@ -14,9 +14,9 @@ use std::sync::atomic::{AtomicI32, Ordering}; use tokio::sync::oneshot; use tracing::{debug, error, info}; -/// Trading room ids, unique across fields so a stale invitation can't -/// reach a newer room. -static NEXT_ROOM: AtomicI32 = AtomicI32::new(1); +/// Trading room and personal shop ids, unique across fields so a stale +/// invitation can't reach a newer room. +pub(super) static NEXT_ROOM: AtomicI32 = AtomicI32::new(1); /// The player's side of a trade: what they put up, held out of their /// inventory until the trade ends. Every way it ends goes through @@ -78,11 +78,12 @@ impl ClientActor { self.to_room(RoomRequest::Decline { room: sn, reason }) .await } + // The room may be a trading room or a personal shop: it says + // which by starting a trade or seating the player. TradeRequest::Visit { sn } => { - if trading.is_some() { + if trading.is_some() || self.personal_shop.is_some() { return Ok(()); } - self.trade = Some(Trade::new(sn, 1)); self.to_room(RoomRequest::Visit { room: sn }).await } TradeRequest::Chat { text } => { @@ -167,7 +168,7 @@ impl ClientActor { } /// The client waits for an answer before it lets the player act again. - async fn unlock(&mut self) -> Result<(), RuntimeError> { + pub(super) async fn unlock(&mut self) -> Result<(), RuntimeError> { self.send(&mut stat::build_stat_update(true, &[])?).await } @@ -177,6 +178,10 @@ impl ClientActor { room: i32, event: TradeEvent, ) -> Result<(), RuntimeError> { + // A visitor's trade starts once the room lets them in. + if self.trade.is_none() && matches!(event, TradeEvent::Started) { + self.trade = Some(Trade::new(room, 1)); + } let Some(trade) = self.trade.as_mut().filter(|t| t.room == room) else { return Ok(()); }; @@ -215,11 +220,8 @@ impl ClientActor { let Some(trade) = self.trade.as_ref() else { return Ok(()); }; - // A visitor not yet let in has no room open to close. - let leave = (trade.started || trade.slot == 0) - .then(|| build_room_leave(trade.slot, leave_reason::FAILED)) - .transpose()?; - self.finish_trade(leave).await + let leave = build_room_leave(trade.slot, leave_reason::FAILED)?; + self.finish_trade(Some(leave)).await } /// End the trade: the player gets the partner's offer if the room @@ -242,30 +244,13 @@ impl ClientActor { let done = traded.is_some(); let back = traded.unwrap_or(trade.offer); let meso = back.meso; - let game = self.game.as_mut().ok_or(RuntimeError::NotLoggedIn)?; - let given = trade::give(&mut game.player, back); - if !given.overflow.is_empty() { - let (x, y) = game.position; - let spill = FieldMessage::SpawnLoot { - owner: self.client_id, - loot: given.overflow, - x, - y, - dropper: 0, - }; - if game.field.tx.send(spill).await.is_err() { - error!(self.client_id, "Field gone; trade overflow lost"); - } - } - let player_meso = game.player.meso; + let changes = self.receive(back).await?; + let player_meso = self.in_game()?.player.meso; info!(self.client_id, room = trade.room, done, "Trade ended"); - if !given.changes.is_empty() { - self.send(&mut inventory::build_inventory_operation( - false, - &given.changes, - )?) - .await?; + if !changes.is_empty() { + self.send(&mut inventory::build_inventory_operation(false, &changes)?) + .await?; } if meso > 0 { self.send(&mut stat::build_stat_update( @@ -280,6 +265,30 @@ impl ClientActor { Ok(()) } + /// Give the player `offer`; whatever no longer fits drops at their + /// feet. Returns the inventory changes, for the caller to send. + pub(super) async fn receive( + &mut self, + offer: Offer, + ) -> Result, RuntimeError> { + let game = self.game.as_mut().ok_or(RuntimeError::NotLoggedIn)?; + let given = trade::give(&mut game.player, offer); + if !given.overflow.is_empty() { + let (x, y) = game.position; + let spill = FieldMessage::SpawnLoot { + owner: self.client_id, + loot: given.overflow, + x, + y, + dropper: 0, + }; + if game.field.tx.send(spill).await.is_err() { + error!(self.client_id, "Field gone; overflow lost"); + } + } + Ok(given.changes) + } + async fn to_room(&mut self, request: RoomRequest) -> Result<(), RuntimeError> { self.send_to_field(FieldMessage::Trade { from: self.client_id, diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index 792174a9c..b24e8f3c3 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -1,10 +1,14 @@ +use crate::actor::guild_invites::GuildInvites; use crate::actor::messenger::{Messengers, Outbox}; use crate::actor::parties::{self, Member, Parties, Party}; use crate::message::{ClientHandle, DirectoryMessage, ListedBuddy, Presence, ServerMessage}; use game::Guild; use net::packet::build::world::buddy::{build_buddy_list, BuddyView}; use net::packet::build::world::door::{build_spawn_portal, DoorView}; -use net::packet::build::world::guild::build_guild_info; +use net::packet::build::world::guild::{ + build_guild_error, build_guild_info, build_guild_invite, build_invite_refused, GuildError, + InviteRefusal, +}; use net::packet::build::world::map::{build_blocked_map, BlockedMap}; use net::packet::build::world::messaging::{build_find_result, build_whisper_result, FindLocation}; use net::packet::build::world::party::{ @@ -60,6 +64,7 @@ pub struct WorldDirectory { clients: HashMap, names: HashMap, parties: Parties, + guild_invites: GuildInvites, messengers: Messengers, /// Each player's Mystic Door, which their party may use too. doors: HashMap, @@ -72,6 +77,7 @@ impl WorldDirectory { clients: HashMap::new(), names: HashMap::new(), parties: Parties::default(), + guild_invites: GuildInvites::default(), messengers: Messengers::default(), doors: HashMap::new(), } @@ -131,6 +137,24 @@ impl WorldDirectory { } } } + DirectoryMessage::GuildInvite { + from, + guild_id, + name, + } => self.guild_invite(from, guild_id, &name, Instant::now()), + DirectoryMessage::GuildAccept { from, guild_id } => { + if self.guild_invites.accept(from, guild_id, Instant::now()) { + self.tell(from, ServerMessage::JoinGuild { guild_id }); + } + } + DirectoryMessage::GuildDeny { + from, + inviter, + refusal, + } => self.guild_deny(from, &inviter, refusal), + DirectoryMessage::GuildChanged { client_id } => { + self.tell(client_id, ServerMessage::GuildChanged) + } DirectoryMessage::Messenger { from, request } => self.messenger(from, request), DirectoryMessage::LookChanged { client_id, look } => { let out = self.messengers.look_changed(client_id, look); @@ -204,6 +228,7 @@ impl WorldDirectory { self.deliver(out); } self.parties.forget_invites(client_id); + self.guild_invites.forget(client_id); if let Some(party) = self.parties.of(client_id) { self.send_party_update(party); } @@ -353,6 +378,48 @@ impl WorldDirectory { self.send_packet(owner, build_guild_info(Some(&guild))); } + /// Pass on an invitation to someone on the inviter's channel who is not + /// in a guild. + fn guild_invite(&mut self, from: ClientId, guild_id: i32, name: &str, now: Instant) { + let Some(inviter) = self.clients.get(&from) else { + return; + }; + let invitee = self.names.get(name).and_then(|id| self.clients.get(id)); + let Some(invitee) = invitee.filter(|invitee| { + invitee.handle.client_id != from && invitee.channel_id == inviter.channel_id + }) else { + return self.send_packet(from, build_guild_error(GuildError::NotInChannel)); + }; + if invitee.presence.guild_id.is_some() { + return self.send_packet(from, build_guild_error(GuildError::InviteeInGuild)); + } + let (id, name) = (invitee.handle.client_id, invitee.presence.name.clone()); + let packet = build_guild_invite(guild_id, &inviter.presence.name); + if self.guild_invites.invite(guild_id, from, id, now) { + self.send_packet(id, packet); + } else { + let refused = build_invite_refused(InviteRefusal::AnotherInvitation, &name); + self.send_packet(from, refused); + } + } + + /// Tell `inviter` that `from` turned their invitation down. One still + /// shown, from the same guild, is kept: the client refuses a second. + fn guild_deny(&mut self, from: ClientId, inviter: &str, refusal: InviteRefusal) { + let (Some(&inviter), Some(entry)) = (self.names.get(inviter), self.clients.get(&from)) + else { + return; + }; + let pending = if refusal == InviteRefusal::AnotherInvitation { + self.guild_invites.is_invited(from, inviter) + } else { + self.guild_invites.decline(from, inviter) + }; + if pending { + self.send_packet(inviter, build_invite_refused(refusal, &entry.presence.name)); + } + } + fn party_request(&mut self, from: ClientId, request: PartyRequest, now: Instant) { let Some(member) = self.clients.get(&from).map(Entry::member) else { return; @@ -654,6 +721,13 @@ impl WorldDirectory { .filter(move |(_, other)| other.shares_map_with(entry)) } + /// Pass a message to `client`'s actor, if they are online. + fn tell(&self, client: ClientId, message: ServerMessage) { + if let Some(entry) = self.clients.get(&client) { + entry.handle.send(message); + } + } + fn tell_party_changed(&self, client: ClientId, party_id: Option) { if let Some(entry) = self.clients.get(&client) { entry.handle.send(ServerMessage::PartyChanged { party_id }); @@ -709,6 +783,7 @@ mod tests { map_id, hp: 50, max_hp: 50, + guild_id: None, } } @@ -964,6 +1039,101 @@ mod tests { assert_eq!(packet.bytes, expected.bytes); } + #[test] + fn guild_invitations_stay_on_the_channel_and_answers_reach_the_inviter() { + let (_tx, rx) = mpsc::channel(1); + let mut directory = WorldDirectory::new(rx); + let mut alice = online(&mut directory, 1, "Alice", 10000); + let mut bobby = online(&mut directory, 2, "Bobby", 20000); + let (carol, _carol_rx, _) = handle(3, 3); + directory.register(carol, presence("Carol", 10000), 1); + let (dave, _dave_rx, _) = handle(4, 4); + let mut guilded = presence("Dave", 10000); + guilded.guild_id = Some(5); + directory.register(dave, guilded, 0); + let packets = |rx: &mut mpsc::Receiver| { + let mut seen = Vec::new(); + while let Ok(message) = rx.try_recv() { + match message { + ServerMessage::SendPacket(packet) => seen.push(packet.bytes), + other => panic!("unexpected {other:?}"), + } + } + seen + }; + let invite = |directory: &mut WorldDirectory, name: &str| { + directory.handle(DirectoryMessage::GuildInvite { + from: 1, + guild_id: 4, + name: name.into(), + }) + }; + let deny = |directory: &mut WorldDirectory, refusal| { + directory.handle(DirectoryMessage::GuildDeny { + from: 2, + inviter: "Alice".into(), + refusal, + }) + }; + let error = |error| build_guild_error(error).unwrap().bytes; + let refused = |refusal| build_invite_refused(refusal, "Bobby").unwrap().bytes; + + // Carol is on another channel, Dave in a guild. + for name in ["Carol", "Nobody", "Alice"] { + invite(&mut directory, name); + } + invite(&mut directory, "Dave"); + assert_eq!( + packets(&mut alice), + [ + error(GuildError::NotInChannel), + error(GuildError::NotInChannel), + error(GuildError::NotInChannel), + error(GuildError::InviteeInGuild), + ] + ); + + invite(&mut directory, "Bobby"); + assert_eq!( + packets(&mut bobby), + [build_guild_invite(4, "Alice").unwrap().bytes] + ); + // Shown another invitation from the guild, the client refuses it and + // keeps the first. + deny(&mut directory, InviteRefusal::AnotherInvitation); + assert_eq!( + packets(&mut alice), + [refused(InviteRefusal::AnotherInvitation)] + ); + directory.handle(DirectoryMessage::GuildAccept { + from: 2, + guild_id: 4, + }); + directory.handle(DirectoryMessage::GuildAccept { + from: 2, + guild_id: 4, + }); + assert!(matches!( + bobby.try_recv(), + Ok(ServerMessage::JoinGuild { guild_id: 4 }) + )); + assert!(bobby.try_recv().is_err()); + + invite(&mut directory, "Bobby"); + packets(&mut bobby); + deny(&mut directory, InviteRefusal::Declined); + deny(&mut directory, InviteRefusal::Declined); + assert_eq!(packets(&mut alice), [refused(InviteRefusal::Declined)]); + directory.handle(DirectoryMessage::GuildAccept { + from: 2, + guild_id: 4, + }); + assert!(bobby.try_recv().is_err()); + + directory.handle(DirectoryMessage::GuildChanged { client_id: 2 }); + assert!(matches!(bobby.try_recv(), Ok(ServerMessage::GuildChanged))); + } + #[test] fn duplicate_login_kicks_the_previous_connection_only() { let (_tx, rx) = mpsc::channel(1); diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index b7f80097e..4d5ec12c3 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -2,6 +2,7 @@ use crate::actor::field_drops::{Drop, DropSet}; use crate::actor::field_mists::MistSet; use crate::actor::field_mobs::{split_exp, Cast, MobSet, Sharer}; use crate::actor::field_reactors::{Hit, ReactorSet}; +use crate::actor::field_shops::{ShopRoomRequest, Shops}; use crate::actor::field_summons::SummonSet; use crate::actor::field_trades::{Outbox, RoomRequest, Trades}; use crate::message::{ClientHandle, FieldKey, FieldMessage, ServerMessage}; @@ -14,6 +15,7 @@ use net::packet::build::world::drop::{build_drop, build_remove_drop, DropAppeara use net::packet::build::world::field::{ build_player_enter_field, build_player_leave_field, build_show_chair, parse_movement_state, }; +use net::packet::build::world::mini_room::build_char_box; use net::packet::build::world::mist::{build_remove_mist, build_spawn_mist, MistKind, MistView}; use net::packet::build::world::mob::{ build_control_mob, build_heal_mob, build_kill_mob, build_mob_hp, build_mob_stat_reset, @@ -92,6 +94,7 @@ pub struct FieldActor { /// Players' Mystic Doors here, by owner. doors: HashMap, trades: Trades, + shops: Shops, /// On a ship route's maps, the route time trips were last checked at. trips: Option, tick: Duration, @@ -123,6 +126,7 @@ impl FieldActor { summons: SummonSet::default(), doors: HashMap::new(), trades: Trades::default(), + shops: Shops::default(), trips: conti::has_trips(key.map_id).then(conti::now), tick, } @@ -286,13 +290,15 @@ impl FieldActor { self.broadcast(Some(client_id), packet); } if let Some(occupant) = self.occupants.get_mut(&client_id) { - // Keep the live position and chair; take everything else. - let live = &occupant.avatar; + // Keep the live position, chair and balloon; take + // everything else. + let live = &mut occupant.avatar; occupant.avatar = FieldAvatar { x: live.x, y: live.y, stance: live.stance, chair: live.chair, + mini_room: live.mini_room.take(), ..avatar }; } @@ -443,6 +449,7 @@ impl FieldActor { } } FieldMessage::Trade { from, request } => self.handle_trade(from, request), + FieldMessage::PersonalShop { from, request } => self.handle_shop(from, request), } } @@ -454,13 +461,24 @@ impl FieldActor { }; let (name, look) = (&occupant.avatar.name, &occupant.avatar.look); let out = match request { - RoomRequest::Create { room } => self.trades.create(room, from, name, look), + RoomRequest::Create { room } => { + self.trades + .create(room, from, name, look, self.shops.is_member(from)) + } RoomRequest::Invite { target } => { let target_name = self.occupants.get(&target).map(|o| o.avatar.name.as_str()); self.trades.invite(from, target, target_name) } RoomRequest::Decline { room, reason } => self.trades.decline(from, room, reason, name), - RoomRequest::Visit { room } => self.trades.visit(from, room, name, look), + // Shops and trading rooms share the visit. + RoomRequest::Visit { room } if self.shops.contains(room) => { + self.shops + .visit(from, room, name, look, self.trades.is_member(from)) + } + RoomRequest::Visit { room } => { + self.trades + .visit(from, room, name, look, self.shops.is_member(from)) + } RoomRequest::Chat { text } => self.trades.chat(from, &text), RoomRequest::PutItem { slot, item } => self.trades.put_item(from, slot, item), RoomRequest::Meso { total } => self.trades.meso(from, total), @@ -469,6 +487,69 @@ impl FieldActor { RoomRequest::Leave => self.trades.leave(from), }; self.deliver_trade(out); + self.show_balloons(); + } + + /// A personal shop request from an occupant; anyone else's is dropped, + /// with any oneshot it carries. + fn handle_shop(&mut self, from: ClientId, request: ShopRoomRequest) { + let Some(occupant) = self.occupants.get(&from) else { + return; + }; + let (name, look) = (&occupant.avatar.name, &occupant.avatar.look); + let shops = &mut self.shops; + let out = match request { + ShopRoomRequest::Create { room, title, skin } => { + let busy = self.trades.is_member(from); + shops.create(room, from, name, look, title, skin, busy) + } + ShopRoomRequest::Listings(listings) => shops.listings(from, listings), + ShopRoomRequest::Open => { + shops.open(from); + Outbox::new() + } + ShopRoomRequest::Chat { text } => shops.chat(from, &text), + ShopRoomRequest::Quote { index, reply } => { + shops.quote(from, index, reply); + Outbox::new() + } + ShopRoomRequest::Purchase(sale) => shops.purchase(from, sale), + ShopRoomRequest::Ban { + slot, + name, + overstayed, + } => shops.ban(from, slot, &name, overstayed), + ShopRoomRequest::Blacklist(names) => { + shops.blacklist(from, names); + Outbox::new() + } + ShopRoomRequest::BlacklistAdd(name) => { + shops.blacklist_add(from, name); + Outbox::new() + } + ShopRoomRequest::BlacklistRemove(name) => { + shops.blacklist_remove(from, &name); + Outbox::new() + } + ShopRoomRequest::SoldOut => shops.sold_out(from), + ShopRoomRequest::Leave => shops.leave(from), + }; + self.deliver_trade(out); + self.show_balloons(); + } + + /// Put up, change, or take down the balloons over shop owners. + fn show_balloons(&mut self) { + for (owner, balloon) in self.shops.take_balloons() { + let Some(occupant) = self.occupants.get_mut(&owner) else { + continue; + }; + occupant.avatar.mini_room = balloon; + match build_char_box(owner, occupant.avatar.mini_room.as_ref()) { + Ok(packet) => self.broadcast(None, packet), + Err(error) => warn!(owner, %error, "Failed to build a shop balloon"), + } + } } fn deliver_trade(&self, out: Outbox) { @@ -1363,9 +1444,12 @@ impl FieldActor { let client_id = handle.client_id; // A rejoin replaces the previous entry without announcing a leave. self.occupants.remove(&client_id); - // It also ends any trade the player had here. + // It also ends any trade or shop the player had here. let out = self.trades.leave(client_id); self.deliver_trade(out); + let out = self.shops.leave(client_id); + self.deliver_trade(out); + self.show_balloons(); // Its summons come back with it. for summon in self.summons.remove_owned(client_id, None) { self.remove_summon(&summon.view, Gone::Silently); @@ -1456,6 +1540,9 @@ impl FieldActor { } let out = self.trades.leave(client_id); self.deliver_trade(out); + let out = self.shops.leave(client_id); + self.deliver_trade(out); + self.show_balloons(); // The others drop a player's summons with the player. self.summons.remove_owned(client_id, None); // Everyone sees the player go before control passes on. @@ -1560,6 +1647,7 @@ mod tests { chair: 0, guild: None, buffs: Vec::new(), + mini_room: None, } } diff --git a/runtime/src/actor/field_shops.rs b/runtime/src/actor/field_shops.rs new file mode 100644 index 000000000..0c37d36cc --- /dev/null +++ b/runtime/src/actor/field_shops.rs @@ -0,0 +1,698 @@ +//! Personal shops on one field. Pure state, like `field_trades`: this module +//! decides who gets which message, and `FieldActor` delivers them. +//! +//! The owner's client actor holds the stock; the room keeps a copy of the +//! listings to show visitors and to quote a price. A purchase goes from the +//! buyer, already charged, through the room to the owner, who sells only +//! if the stock is still there and the buyer is still waiting for the +//! oneshot; otherwise the buyer is refunded. + +use crate::actor::field_trades::Outbox; +use crate::message::ServerMessage; +use game::{AvatarLook, Item, MiniRoomBox}; +use net::error::NetworkError; +use net::packet::build::world::mini_room::{ + build_enter_error, build_room_chat, build_room_leave, build_room_visit, build_seat_left, + build_shop_items, build_shop_room, enter_error, leave_reason, PERSONAL_SHOP, SHOP_USERS, +}; +use net::personal_shop::{Listing, VISITORS}; +use net::ClientId; +use packet::Packet; +use std::collections::HashMap; +use tokio::sync::oneshot; +use tracing::error; + +/// What a client asks of a personal shop on its field. Visits come as +/// `RoomRequest::Visit`, the same as for trading rooms. +#[derive(Debug)] +pub enum ShopRoomRequest { + /// Open shop `room`, the sender its owner. Visitors may enter once it + /// opens. + Create { + room: i32, + title: String, + skin: u8, + }, + /// The owner's listings changed. + Listings(Vec), + Open, + Chat { + text: String, + }, + /// What listing `index` is now. + Quote { + index: u8, + reply: oneshot::Sender>, + }, + /// A visitor, already charged, buys from the owner. + Purchase(Sale), + /// The owner turns out the visitor in `slot`, named `name`. + Ban { + slot: u8, + name: String, + overstayed: bool, + }, + /// The owner's blacklist: these may not enter. + Blacklist(Vec), + BlacklistAdd(String), + BlacklistRemove(String), + /// The owner sold everything: the shop closes for all. + SoldOut, + Leave, +} + +/// A purchase on its way to the owner. +#[derive(Debug)] +pub struct Sale { + pub index: u8, + pub bundles: i16, + /// The item the buyer was quoted. + pub item_id: i32, + /// What the buyer paid. + pub paid: i32, + pub buyer: String, + /// The items, or the `buy_result` that says why not. Dropped unanswered + /// if the shop is gone. + pub reply: oneshot::Sender>, +} + +/// What a shop tells a member's client actor. +#[derive(Debug)] +pub enum ShopEvent { + /// The player sits in `slot` of the shop now. + Entered { slot: u8 }, + /// The owner: a visitor buys. + Sale(Sale), + /// The player is out of the shop; send the packet, if any. + Left(Option), +} + +struct Member { + client_id: ClientId, + name: String, + look: AvatarLook, +} + +struct Shop { + owner: Member, + visitors: [Option; VISITORS as usize], + title: String, + skin: u8, + open: bool, + listings: Vec, + blacklist: Vec, + /// Turned out this time the shop is open. + banned: Vec, +} + +impl Shop { + /// Everyone in the shop with their slot. + fn members(&self) -> impl Iterator { + std::iter::once((0, &self.owner)).chain( + self.visitors + .iter() + .enumerate() + .filter_map(|(i, seat)| Some((i as u8 + 1, seat.as_ref()?))), + ) + } + + fn barred(&self, name: &str) -> bool { + let same = |barred: &String| barred.eq_ignore_ascii_case(name); + self.blacklist.iter().any(same) || self.banned.iter().any(same) + } + + fn balloon(&self, room: i32) -> MiniRoomBox { + MiniRoomBox { + kind: PERSONAL_SHOP, + sn: room, + title: self.title.clone(), + skin: self.skin, + users: self.members().count() as u8, + max_users: SHOP_USERS, + } + } +} + +#[derive(Default)] +pub struct Shops { + rooms: HashMap, + member_of: HashMap, + /// Owners whose balloon changed (`None`: it went), for the field to + /// show. + balloons: Vec<(ClientId, Option)>, +} + +fn packet(out: &mut Outbox, to: ClientId, packet: Result) { + match packet { + Ok(packet) => out.push((to, ServerMessage::SendPacket(packet))), + Err(e) => error!(to, error = %e, "Failed to build a shop packet"), + } +} + +fn event(out: &mut Outbox, to: ClientId, room: i32, event: ShopEvent) { + out.push((to, ServerMessage::PersonalShop { room, event })); +} + +/// A packet to send with an event, if it builds. +fn leave_packet(packet: Result) -> Option { + packet + .map_err(|e| error!(error = %e, "Failed to build a shop packet")) + .ok() +} + +impl Shops { + pub fn contains(&self, room: i32) -> bool { + self.rooms.contains_key(&room) + } + + pub fn is_member(&self, client_id: ClientId) -> bool { + self.member_of.contains_key(&client_id) + } + + /// The balloons that changed since last asked. + pub fn take_balloons(&mut self) -> Vec<(ClientId, Option)> { + std::mem::take(&mut self.balloons) + } + + /// The shop `client_id` is in, with their slot. + fn shop_of(&mut self, client_id: ClientId) -> Option<(i32, &mut Shop, u8)> { + let id = *self.member_of.get(&client_id)?; + let shop = self.rooms.get_mut(&id)?; + let (slot, _) = shop.members().find(|(_, m)| m.client_id == client_id)?; + Some((id, shop, slot)) + } + + /// The shop `client_id` owns. + fn owned(&mut self, client_id: ClientId) -> Option<(i32, &mut Shop)> { + self.shop_of(client_id) + .filter(|(_, _, slot)| *slot == 0) + .map(|(id, shop, _)| (id, shop)) + } + + /// `busy`: the owner is in another kind of room. + pub fn create( + &mut self, + room: i32, + owner: ClientId, + name: &str, + look: &AvatarLook, + title: String, + skin: u8, + busy: bool, + ) -> Outbox { + let mut out = Outbox::new(); + if busy || self.member_of.contains_key(&owner) || self.rooms.contains_key(&room) { + event(&mut out, owner, room, ShopEvent::Left(None)); + return out; + } + packet( + &mut out, + owner, + build_shop_room(0, &[(0, look, name)], &title, &[]), + ); + self.rooms.insert( + room, + Shop { + owner: Member { + client_id: owner, + name: name.to_string(), + look: look.clone(), + }, + visitors: Default::default(), + title, + skin, + open: false, + listings: Vec::new(), + blacklist: Vec::new(), + banned: Vec::new(), + }, + ); + self.member_of.insert(owner, room); + out + } + + /// Show the owner's listings to everyone in the shop. + pub fn listings(&mut self, from: ClientId, listings: Vec) -> Outbox { + let mut out = Outbox::new(); + let Some((_, shop)) = self.owned(from) else { + return out; + }; + for (_, member) in shop.members() { + packet(&mut out, member.client_id, build_shop_items(&listings)); + } + shop.listings = listings; + out + } + + pub fn open(&mut self, from: ClientId) { + let Some((id, shop)) = self.owned(from).filter(|(_, shop)| !shop.open) else { + return; + }; + shop.open = true; + let balloon = shop.balloon(id); + self.balloons.push((from, Some(balloon))); + } + + /// `busy`: the visitor is in another kind of room. + pub fn visit( + &mut self, + from: ClientId, + room: i32, + name: &str, + look: &AvatarLook, + busy: bool, + ) -> Outbox { + let mut out = Outbox::new(); + let refusal = match self.rooms.get(&room) { + _ if busy || self.member_of.contains_key(&from) => Some(enter_error::CLOSED), + Some(shop) if shop.open && shop.barred(name) => Some(enter_error::BANNED), + Some(shop) if shop.open => shop + .visitors + .iter() + .all(Option::is_some) + .then_some(enter_error::FULL), + _ => Some(enter_error::CLOSED), + }; + if let Some(refusal) = refusal { + packet(&mut out, from, build_enter_error(refusal)); + return out; + } + let shop = self.rooms.get_mut(&room).expect("checked above"); + let free = shop + .visitors + .iter() + .position(Option::is_none) + .expect("checked above"); + let slot = free as u8 + 1; + shop.visitors[free] = Some(Member { + client_id: from, + name: name.to_string(), + look: look.clone(), + }); + // Before the packets, so a purchase made on seeing them finds the + // player in the shop. + event(&mut out, from, room, ShopEvent::Entered { slot }); + let members: Vec<_> = shop + .members() + .map(|(slot, m)| (slot, &m.look, m.name.as_str())) + .collect(); + packet( + &mut out, + from, + build_shop_room(slot, &members, &shop.title, &shop.listings), + ); + for (_, member) in shop.members().filter(|(s, _)| *s != slot) { + packet( + &mut out, + member.client_id, + build_room_visit(slot, look, name), + ); + } + self.member_of.insert(from, room); + let balloon = shop.balloon(room); + self.balloons.push((shop.owner.client_id, Some(balloon))); + out + } + + pub fn chat(&mut self, from: ClientId, text: &str) -> Outbox { + let mut out = Outbox::new(); + let Some((_, shop, slot)) = self.shop_of(from) else { + return out; + }; + let name = &shop + .members() + .find(|(s, _)| *s == slot) + .expect("a member") + .1 + .name; + let line = format!("{name} : {text}"); + for (_, member) in shop.members() { + packet(&mut out, member.client_id, build_room_chat(slot, &line)); + } + out + } + + /// Answer a visitor's question about a listing; anyone else's reply is + /// dropped. + pub fn quote(&mut self, from: ClientId, index: u8, reply: oneshot::Sender>) { + if let Some((_, shop, slot)) = self.shop_of(from) { + if slot != 0 && shop.open { + let _ = reply.send(shop.listings.get(usize::from(index)).cloned()); + } + } + } + + /// Pass a visitor's purchase on to the owner; anyone else's is dropped, + /// which refunds them. + pub fn purchase(&mut self, from: ClientId, sale: Sale) -> Outbox { + let mut out = Outbox::new(); + if let Some((id, shop, slot)) = self.shop_of(from) { + if slot != 0 && shop.open { + event(&mut out, shop.owner.client_id, id, ShopEvent::Sale(sale)); + } + } + out + } + + /// The owner turns out the visitor in `slot`, if it is still `name`, + /// and keeps them out while the shop is open. + pub fn ban(&mut self, from: ClientId, slot: u8, name: &str, overstayed: bool) -> Outbox { + let mut out = Outbox::new(); + let Some((id, shop)) = self.owned(from) else { + return out; + }; + let seat = usize::from(slot).wrapping_sub(1); + let Some(visitor) = shop + .visitors + .get_mut(seat) + .filter(|seat| seat.as_ref().is_some_and(|v| v.name == name)) + .and_then(Option::take) + else { + return out; + }; + shop.banned.push(visitor.name); + let reason = if overstayed { + leave_reason::OVERSTAYED + } else { + leave_reason::BANNED + }; + let leave = leave_packet(build_room_leave(slot, reason)); + event(&mut out, visitor.client_id, id, ShopEvent::Left(leave)); + for (_, member) in shop.members() { + packet(&mut out, member.client_id, build_seat_left(slot)); + } + let balloon = shop.balloon(id); + self.member_of.remove(&visitor.client_id); + self.balloons.push((from, Some(balloon))); + out + } + + pub fn blacklist(&mut self, from: ClientId, names: Vec) { + if let Some((_, shop)) = self.owned(from) { + shop.blacklist = names; + } + } + + pub fn blacklist_add(&mut self, from: ClientId, name: String) { + if let Some((_, shop)) = self.owned(from) { + shop.blacklist.push(name); + } + } + + pub fn blacklist_remove(&mut self, from: ClientId, name: &str) { + if let Some((_, shop)) = self.owned(from) { + shop.blacklist + .retain(|barred| !barred.eq_ignore_ascii_case(name)); + } + } + + /// The owner sold everything: everyone, the owner too, is told the + /// items are out of stock. + pub fn sold_out(&mut self, from: ClientId) -> Outbox { + let Some((id, _)) = self.owned(from) else { + return Outbox::new(); + }; + let mut out = self.close(id, leave_reason::OUT_OF_STOCK); + packet( + &mut out, + from, + build_room_leave(0, leave_reason::OUT_OF_STOCK), + ); + out + } + + /// `client_id` left the shop or the field. An owner leaving closes the + /// shop; the owner's client actor already took the stock back. + pub fn leave(&mut self, client_id: ClientId) -> Outbox { + let mut out = Outbox::new(); + let Some((id, shop, slot)) = self.shop_of(client_id) else { + return out; + }; + if slot == 0 { + return self.close(id, leave_reason::SHOP_CLOSED); + } + shop.visitors[usize::from(slot) - 1] = None; + for (_, member) in shop.members() { + packet(&mut out, member.client_id, build_seat_left(slot)); + } + let balloon = shop.balloon(id); + let owner = shop.owner.client_id; + self.member_of.remove(&client_id); + if self.rooms[&id].open { + self.balloons.push((owner, Some(balloon))); + } + out + } + + /// Close a shop: the visitors get the LEAVE with `reason`, and the + /// balloon goes. + fn close(&mut self, id: i32, reason: u8) -> Outbox { + let mut out = Outbox::new(); + let Some(shop) = self.rooms.remove(&id) else { + return out; + }; + for (slot, member) in shop.members() { + self.member_of.remove(&member.client_id); + if slot != 0 { + let leave = leave_packet(build_room_leave(slot, reason)); + event(&mut out, member.client_id, id, ShopEvent::Left(leave)); + } + } + if shop.open { + self.balloons.push((shop.owner.client_id, None)); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + const ALICE: ClientId = 1; + const BOB: ClientId = 2; + const CAROL: ClientId = 3; + const ROOM: i32 = 77; + + fn look() -> AvatarLook { + AvatarLook { + gender: 0, + skin: 0, + face: 20000, + hair: 30000, + visible: BTreeMap::new(), + masked: BTreeMap::new(), + cash_weapon: None, + } + } + + fn potions(bundles: i16) -> Listing { + Listing { + item: Item::stack(2000000, 10), + bundles, + price: 500, + } + } + + /// The mode byte of a packet (and the slot and reason of a LEAVE), or + /// what kind of event it is. + fn summary(out: &Outbox) -> Vec<(ClientId, String)> { + out.iter() + .map(|(to, message)| { + let what = match message { + ServerMessage::SendPacket(packet) => describe(packet), + ServerMessage::PersonalShop { event, .. } => match event { + ShopEvent::Entered { slot } => format!("entered {slot}"), + ShopEvent::Sale(sale) => format!("sale {}", sale.paid), + ShopEvent::Left(Some(packet)) => format!("left {}", describe(packet)), + ShopEvent::Left(None) => "left".to_string(), + }, + other => format!("{other:?}"), + }; + (*to, what) + }) + .collect() + } + + fn describe(packet: &Packet) -> String { + match packet.bytes[2] { + 0x0a => format!("0xa {:?}", &packet.bytes[3..]), + mode => format!("{mode:#x}"), + } + } + + fn s(to: ClientId, what: &str) -> (ClientId, String) { + (to, what.to_string()) + } + + fn open_shop() -> Shops { + let mut shops = Shops::default(); + shops.create(ROOM, ALICE, "Alice", &look(), "Pots".into(), 2, false); + shops.listings(ALICE, vec![potions(3)]); + shops.open(ALICE); + shops.take_balloons(); + shops + } + + #[test] + fn an_owner_sets_up_then_opens_with_a_balloon() { + let mut shops = Shops::default(); + let out = shops.create(ROOM, ALICE, "Alice", &look(), "Pots".into(), 2, false); + assert_eq!(summary(&out), vec![s(ALICE, "0x5")]); + // Nobody may come in yet. + let out = shops.visit(BOB, ROOM, "Bob", &look(), false); + assert_eq!(summary(&out), vec![s(BOB, "0x5")]); + let out = shops.listings(ALICE, vec![potions(3)]); + assert_eq!(summary(&out), vec![s(ALICE, "0x19")]); + assert!(shops.take_balloons().is_empty()); + shops.open(ALICE); + let balloons = shops.take_balloons(); + assert_eq!(balloons.len(), 1); + let (owner, balloon) = &balloons[0]; + let balloon = balloon.as_ref().unwrap(); + assert_eq!(*owner, ALICE); + assert_eq!( + ( + balloon.sn, + balloon.title.as_str(), + balloon.skin, + balloon.users + ), + (ROOM, "Pots", 2, 1) + ); + // Someone busy, or in a room already, can't open one. + let out = shops.create(ROOM + 1, ALICE, "Alice", &look(), "x".into(), 0, false); + assert_eq!(summary(&out), vec![s(ALICE, "left")]); + let out = shops.create(ROOM + 1, BOB, "Bob", &look(), "x".into(), 0, true); + assert_eq!(summary(&out), vec![s(BOB, "left")]); + } + + #[test] + fn visitors_take_free_seats_up_to_three() { + let mut shops = open_shop(); + let out = shops.visit(BOB, ROOM, "Bob", &look(), false); + assert_eq!( + summary(&out), + vec![s(BOB, "entered 1"), s(BOB, "0x5"), s(ALICE, "0x4")] + ); + assert_eq!(shops.take_balloons()[0].1.as_ref().unwrap().users, 2); + shops.visit(CAROL, ROOM, "Carol", &look(), false); + shops.visit(4, ROOM, "Dave", &look(), false); + let out = shops.visit(5, ROOM, "Erin", &look(), false); + assert_eq!(summary(&out), vec![s(5, "0x5")]); + assert_eq!(out_bytes(&out)[4], enter_error::FULL); + + // Bob's seat frees up for the next. + let out = shops.leave(BOB); + assert_eq!( + summary(&out), + vec![s(ALICE, "0xa [1]"), s(CAROL, "0xa [1]"), s(4, "0xa [1]")] + ); + let out = shops.visit(5, ROOM, "Erin", &look(), false); + assert_eq!(summary(&out)[0], s(5, "entered 1")); + } + + fn out_bytes(out: &Outbox) -> Vec { + match &out[0].1 { + ServerMessage::SendPacket(packet) => packet.bytes.clone(), + other => panic!("not a packet: {other:?}"), + } + } + + #[test] + fn chat_goes_to_everyone_in_the_shop() { + let mut shops = open_shop(); + shops.visit(BOB, ROOM, "Bob", &look(), false); + let out = shops.chat(BOB, "hi"); + assert_eq!(summary(&out), vec![s(ALICE, "0x6"), s(BOB, "0x6")]); + assert!(shops.chat(CAROL, "hi").is_empty()); + } + + #[test] + fn purchases_reach_the_owner_and_listings_reach_everyone() { + let mut shops = open_shop(); + shops.visit(BOB, ROOM, "Bob", &look(), false); + let (reply, mut quote) = oneshot::channel(); + shops.quote(BOB, 0, reply); + assert_eq!(quote.try_recv(), Ok(Some(potions(3)))); + let (reply, mut quote) = oneshot::channel(); + shops.quote(BOB, 1, reply); + assert_eq!(quote.try_recv(), Ok(None)); + // Only visitors are quoted. + let (reply, mut quote) = oneshot::channel(); + shops.quote(CAROL, 0, reply); + assert!(quote.try_recv().is_err()); + + let sale = |paid| { + let (reply, rx) = oneshot::channel(); + let sale = Sale { + index: 0, + bundles: 2, + item_id: 2000000, + paid, + buyer: "Bob".into(), + reply, + }; + (sale, rx) + }; + let (purchase, _rx) = sale(1000); + let out = shops.purchase(BOB, purchase); + assert_eq!(summary(&out), vec![s(ALICE, "sale 1000")]); + let (purchase, mut rx) = sale(1000); + assert!(shops.purchase(CAROL, purchase).is_empty()); + assert!(rx.try_recv().is_err()); + + let out = shops.listings(ALICE, vec![potions(1)]); + assert_eq!(summary(&out), vec![s(ALICE, "0x19"), s(BOB, "0x19")]); + } + + #[test] + fn banned_and_blacklisted_names_stay_out() { + let mut shops = open_shop(); + shops.blacklist(ALICE, vec!["carol".into()]); + let out = shops.visit(CAROL, ROOM, "Carol", &look(), false); + assert_eq!(out_bytes(&out)[4], enter_error::BANNED); + + shops.visit(BOB, ROOM, "Bob", &look(), false); + // The slot must still hold the name. + assert!(shops.ban(ALICE, 1, "Carol", false).is_empty()); + let out = shops.ban(ALICE, 1, "Bob", false); + assert_eq!( + summary(&out), + vec![s(BOB, "left 0xa [1, 5]"), s(ALICE, "0xa [1]")] + ); + let out = shops.visit(BOB, ROOM, "Bob", &look(), false); + assert_eq!(out_bytes(&out)[4], enter_error::BANNED); + + shops.blacklist_remove(ALICE, "Carol"); + let out = shops.visit(CAROL, ROOM, "Carol", &look(), false); + assert_eq!(summary(&out)[0], s(CAROL, "entered 1")); + let out = shops.ban(ALICE, 1, "Carol", true); + assert_eq!(summary(&out)[0], s(CAROL, "left 0xa [1, 15]")); + } + + #[test] + fn the_owner_leaving_or_selling_out_closes_the_shop() { + let mut shops = open_shop(); + shops.visit(BOB, ROOM, "Bob", &look(), false); + shops.take_balloons(); + let out = shops.leave(ALICE); + assert_eq!(summary(&out), vec![s(BOB, "left 0xa [1, 3]")]); + assert_eq!(shops.take_balloons(), vec![(ALICE, None)]); + assert!(shops.rooms.is_empty() && shops.member_of.is_empty()); + + let mut shops = open_shop(); + shops.visit(BOB, ROOM, "Bob", &look(), false); + let out = shops.sold_out(ALICE); + assert_eq!( + summary(&out), + vec![s(BOB, "left 0xa [1, 14]"), s(ALICE, "0xa [0, 14]")] + ); + // Only the owner can. + let mut shops = open_shop(); + shops.visit(BOB, ROOM, "Bob", &look(), false); + assert!(shops.sold_out(BOB).is_empty()); + assert!(shops.contains(ROOM)); + } +} diff --git a/runtime/src/actor/field_trades.rs b/runtime/src/actor/field_trades.rs index 630fca688..b6e018adf 100644 --- a/runtime/src/actor/field_trades.rs +++ b/runtime/src/actor/field_trades.rs @@ -138,15 +138,21 @@ impl Trades { Some((id, room, slot)) } + pub fn is_member(&self, client_id: ClientId) -> bool { + self.member_of.contains_key(&client_id) + } + + /// `busy`: the creator is in another kind of room. pub fn create( &mut self, room: i32, client_id: ClientId, name: &str, look: &AvatarLook, + busy: bool, ) -> Outbox { let mut out = Outbox::new(); - if self.member_of.contains_key(&client_id) || self.rooms.contains_key(&room) { + if busy || self.member_of.contains_key(&client_id) || self.rooms.contains_key(&room) { event(&mut out, client_id, room, TradeEvent::Ended(None)); return out; } @@ -224,9 +230,18 @@ impl Trades { } /// `from` accepts `room`'s invitation; the room must still wait for them. - pub fn visit(&mut self, from: ClientId, room: i32, name: &str, look: &AvatarLook) -> Outbox { + /// `busy`: they are in another kind of room. + pub fn visit( + &mut self, + from: ClientId, + room: i32, + name: &str, + look: &AvatarLook, + busy: bool, + ) -> Outbox { let mut out = Outbox::new(); - let waiting = !self.member_of.contains_key(&from) + let waiting = !busy + && !self.member_of.contains_key(&from) && self .rooms .get(&room) @@ -458,25 +473,25 @@ mod tests { fn open_room() -> Trades { let mut trades = Trades::default(); - trades.create(ROOM, ALICE, "Alice", &look()); + trades.create(ROOM, ALICE, "Alice", &look(), false); trades.invite(ALICE, BOB, Some("Bob")); - trades.visit(BOB, ROOM, "Bob", &look()); + trades.visit(BOB, ROOM, "Bob", &look(), false); trades } #[test] fn invite_and_visit_fill_the_room() { let mut trades = Trades::default(); - let out = trades.create(ROOM, ALICE, "Alice", &look()); + let out = trades.create(ROOM, ALICE, "Alice", &look(), false); assert_eq!(summary(&out), vec![s(ALICE, "0x5")]); let out = trades.invite(ALICE, CAROL, None); assert_eq!(summary(&out), vec![s(ALICE, "0x3")]); let out = trades.invite(ALICE, BOB, Some("Bob")); assert_eq!(summary(&out), vec![s(BOB, "0x2")]); // Someone not invited finds it closed. - let out = trades.visit(CAROL, ROOM, "Carol", &look()); + let out = trades.visit(CAROL, ROOM, "Carol", &look(), false); assert_eq!(summary(&out), vec![s(CAROL, "0x5"), s(CAROL, "ended")]); - let out = trades.visit(BOB, ROOM, "Bob", &look()); + let out = trades.visit(BOB, ROOM, "Bob", &look(), false); assert_eq!( summary(&out), vec![ @@ -487,7 +502,7 @@ mod tests { ] ); // Bob is busy now. - trades.create(ROOM + 1, CAROL, "Carol", &look()); + trades.create(ROOM + 1, CAROL, "Carol", &look(), false); let out = trades.invite(CAROL, BOB, Some("Bob")); assert_eq!(summary(&out), vec![s(CAROL, "0x3")]); assert_eq!(out_bytes(&out)[3], 2); @@ -503,13 +518,13 @@ mod tests { #[test] fn a_decline_reaches_the_inviter_and_keeps_the_room() { let mut trades = Trades::default(); - trades.create(ROOM, ALICE, "Alice", &look()); + trades.create(ROOM, ALICE, "Alice", &look(), false); trades.invite(ALICE, BOB, Some("Bob")); let out = trades.decline(BOB, ROOM, 3, "Bob"); assert_eq!(summary(&out), vec![s(ALICE, "0x3")]); assert_eq!(out_bytes(&out)[3], 3); // The invitation is spent. - let out = trades.visit(BOB, ROOM, "Bob", &look()); + let out = trades.visit(BOB, ROOM, "Bob", &look(), false); assert_eq!(summary(&out), vec![s(BOB, "0x5"), s(BOB, "ended")]); let out = trades.invite(ALICE, BOB, Some("Bob")); assert_eq!(summary(&out), vec![s(BOB, "0x2")]); @@ -595,10 +610,10 @@ mod tests { #[test] fn a_room_alone_just_closes_and_its_invitation_goes_stale() { let mut trades = Trades::default(); - trades.create(ROOM, ALICE, "Alice", &look()); + trades.create(ROOM, ALICE, "Alice", &look(), false); trades.invite(ALICE, BOB, Some("Bob")); assert!(trades.leave(ALICE).is_empty()); - let out = trades.visit(BOB, ROOM, "Bob", &look()); + let out = trades.visit(BOB, ROOM, "Bob", &look(), false); assert_eq!(summary(&out), vec![s(BOB, "0x5"), s(BOB, "ended")]); } } diff --git a/runtime/src/actor/guild_invites.rs b/runtime/src/actor/guild_invites.rs new file mode 100644 index 000000000..1ddcb5729 --- /dev/null +++ b/runtime/src/actor/guild_invites.rs @@ -0,0 +1,125 @@ +//! Guild invitations waiting for an answer. The guilds themselves live in +//! the database; the world directory keeps only who was asked, by whom, and +//! when. + +use net::ClientId; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// As long as the client shows the invitation. +pub const INVITE_TIMEOUT: Duration = Duration::from_secs(180); + +struct Invite { + guild_id: i32, + inviter: ClientId, + sent: Instant, +} + +#[derive(Default)] +pub struct GuildInvites { + /// By receiver. + invites: HashMap, +} + +impl GuildInvites { + /// Record that `inviter` asked `invitee` to join `guild_id`. False if + /// `invitee` is still considering another guild's invitation. + pub fn invite( + &mut self, + guild_id: i32, + inviter: ClientId, + invitee: ClientId, + now: Instant, + ) -> bool { + if let Some(pending) = self.invites.get(&invitee) { + if pending.guild_id != guild_id && now < pending.sent + INVITE_TIMEOUT { + return false; + } + } + self.invites.insert( + invitee, + Invite { + guild_id, + inviter, + sent: now, + }, + ); + true + } + + /// Take up `invitee`'s invitation to `guild_id`. Returns whether there + /// was one, recent enough. + pub fn accept(&mut self, invitee: ClientId, guild_id: i32, now: Instant) -> bool { + match self.invites.remove(&invitee) { + Some(invite) if invite.guild_id == guild_id && now < invite.sent + INVITE_TIMEOUT => { + true + } + Some(invite) => { + // Another guild's invitation still stands. + self.invites.insert(invitee, invite); + false + } + None => false, + } + } + + /// Whether `inviter`'s invitation to `invitee` is waiting. + pub fn is_invited(&self, invitee: ClientId, inviter: ClientId) -> bool { + self.invites + .get(&invitee) + .is_some_and(|invite| invite.inviter == inviter) + } + + /// Turn down `inviter`'s invitation. Returns whether there was one. + pub fn decline(&mut self, invitee: ClientId, inviter: ClientId) -> bool { + let pending = self.is_invited(invitee, inviter); + if pending { + self.invites.remove(&invitee); + } + pending + } + + /// Forget invitations to a character who went offline. + pub fn forget(&mut self, invitee: ClientId) { + self.invites.remove(&invitee); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_guild_at_a_time_until_it_expires() { + let mut invites = GuildInvites::default(); + let now = Instant::now(); + assert!(invites.invite(4, 1, 2, now)); + // The same guild may ask again; another must wait. + assert!(invites.invite(4, 3, 2, now)); + assert!(!invites.invite(5, 6, 2, now)); + assert!(!invites.accept(2, 5, now)); + assert!(invites.accept(2, 4, now)); + assert!(!invites.accept(2, 4, now)); + + assert!(invites.invite(4, 1, 2, now)); + let later = now + INVITE_TIMEOUT; + assert!(invites.invite(5, 6, 2, later)); + assert!(!invites.accept(2, 4, later)); + assert!(!invites.accept(2, 5, later + INVITE_TIMEOUT)); + } + + #[test] + fn declines_reach_the_inviter_once() { + let mut invites = GuildInvites::default(); + let now = Instant::now(); + invites.invite(4, 1, 2, now); + assert!(!invites.decline(2, 3)); + assert!(invites.is_invited(2, 1)); + assert!(invites.decline(2, 1)); + assert!(!invites.decline(2, 1)); + + invites.invite(4, 1, 2, now); + invites.forget(2); + assert!(!invites.accept(2, 4, now)); + } +} diff --git a/runtime/src/actor/mod.rs b/runtime/src/actor/mod.rs index 2e0d18050..8d1776a4d 100644 --- a/runtime/src/actor/mod.rs +++ b/runtime/src/actor/mod.rs @@ -5,8 +5,10 @@ pub mod field_drops; pub mod field_mists; pub mod field_mobs; pub mod field_reactors; +pub mod field_shops; pub mod field_summons; pub mod field_trades; +pub mod guild_invites; pub mod login; pub mod messenger; pub mod object_ids; diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 61910c79e..0fbd8bd93 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -14,6 +14,7 @@ use game::{AvatarLook, FieldAvatar, Guild, Player}; use net::packet::build::world::door::DoorView; +use net::packet::build::world::guild::InviteRefusal; use net::{ BroadcastScope, ClientId, FieldRequest, MessengerRequest, PartyRequest, PartySkill, WhisperDelivery, @@ -69,6 +70,13 @@ pub enum ServerMessage { PartyChanged { party_id: Option, }, + /// The player's invitation to `guild_id` stands; join it. + JoinGuild { + guild_id: i32, + }, + /// Someone else changed the player's guild (expelled them); read it + /// again. + GuildChanged, /// A party member on `map_id` used a skill that reached this player, /// standing at `origin`. PartySkill { @@ -155,6 +163,11 @@ pub enum ServerMessage { room: i32, event: crate::actor::field_trades::TradeEvent, }, + /// News from personal shop `room` on this player's field. + PersonalShop { + room: i32, + event: crate::actor::field_shops::ShopEvent, + }, SendPacket(Packet), } @@ -288,6 +301,11 @@ pub enum FieldMessage { from: ClientId, request: crate::actor::field_trades::RoomRequest, }, + /// A player's request of a personal shop; purchases carry oneshots. + PersonalShop { + from: ClientId, + request: crate::actor::field_shops::ShopRoomRequest, + }, } /// A way to reach one field actor. @@ -298,7 +316,7 @@ pub struct FieldHandle { } /// What the world directory knows of an online character: enough for party -/// windows and HP bars. +/// windows, HP bars, and guild invitations. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Presence { pub name: String, @@ -307,6 +325,7 @@ pub struct Presence { pub map_id: i32, pub hp: i16, pub max_hp: i16, + pub guild_id: Option, } impl Presence { @@ -319,6 +338,7 @@ impl Presence { map_id: stats.map_id, hp: stats.hp, max_hp: stats.max_hp, + guild_id: player.guild.as_ref().map(|guild| guild.id), } } } @@ -392,6 +412,29 @@ pub enum DirectoryMessage { to: Vec, packet: Packet, }, + /// `from`, a master or jr. master of `guild_id`, invites the character + /// called `name`. + GuildInvite { + from: ClientId, + guild_id: i32, + name: String, + }, + /// `from` accepts the invitation to `guild_id`; if it stands, they are + /// told to join (`ServerMessage::JoinGuild`). + GuildAccept { + from: ClientId, + guild_id: i32, + }, + /// `from` turns down `inviter`'s invitation. + GuildDeny { + from: ClientId, + inviter: String, + refusal: InviteRefusal, + }, + /// `client_id` was put in or out of a guild by someone else. + GuildChanged { + client_id: ClientId, + }, Messenger { from: ClientId, request: MessengerRequest,