From 465a29e97f210455d73ba363114e4a2121f39fa5 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 02:39:40 -0400 Subject: [PATCH] Family separation and precepts: 0x94/0x95 with the Rep and fee (b048 corrects b039's direction), 0x98 leader-only precepts; BtBye, double-click and name prompt with one Yes/No in the client --- HeavenClient/Character/Family.cpp | 93 ++++++++++++ HeavenClient/Character/Family.h | 26 ++++ HeavenClient/IO/FamilyActions.cpp | 43 ++++++ HeavenClient/IO/FamilyActions.h | 10 ++ HeavenClient/IO/UITypes/UIFamilyTree.cpp | 39 ++++- HeavenClient/IO/UITypes/UIFamilyTree.h | 5 + HeavenClient/IO/UITypes/UIInviteCard.cpp | 2 +- HeavenClient/Net/Handlers/FamilyHandlers.cpp | 17 ++- HeavenClient/Net/OutPacket.h | 2 + HeavenClient/Net/Packets/FamilyPackets.h | 18 +++ HeavenClient/tests/family.cpp | 47 ++++++ agent_docs/families.md | 51 ++++++- db/src/family.rs | 100 ++++++++++++- game/src/family.rs | 28 ++++ integration-harness/src/database.rs | 11 ++ integration-harness/tests/families.rs | 145 ++++++++++++++++++- net/src/handler.rs | 16 ++ net/src/packet/build/world/family.rs | 18 +++ net/src/packet/handle/world/family.rs | 57 ++++++++ net/src/packet/handle/world/mod.rs | 3 +- net/src/packet/op/recv.rs | 6 + runtime/src/actor/client.rs | 5 + runtime/src/actor/client/family.rs | 77 +++++++++- runtime/src/actor/directory.rs | 16 ++ runtime/src/message.rs | 14 ++ 25 files changed, 829 insertions(+), 20 deletions(-) diff --git a/HeavenClient/Character/Family.cpp b/HeavenClient/Character/Family.cpp index f11888df..08464031 100644 --- a/HeavenClient/Character/Family.cpp +++ b/HeavenClient/Character/Family.cpp @@ -1,5 +1,6 @@ #include "Family.h" +#include #include #include @@ -57,6 +58,98 @@ namespace ms return node->level > nodes[node->senior].level; } + namespace + { + // StringPool 0x146d-0x1470 and the fee, 0x146c. + constexpr const char* JUNIOR_BOTH = "%d Reputation Points for yourself and %d Reputation Points for your Senior will be deducted\\nand all lower Family members will be affected.\\nDo you really want to separate from the selected character?"; + constexpr const char* JUNIOR_ONE = "%d Reputation Points will be deducted from you, \\nand all lower Family members will be affected.\\nDo you really want to separate from the selected character?"; + constexpr const char* SENIOR_BOTH = "%d Reputation Points for the Senior and %d Reputation Points for the 2nd Senior will be deducted, and all lower Family members will be affected.\\nDo you really want to separate from the selected character?"; + constexpr const char* SENIOR_ONE = "%d Reputations for the Senior will be deducted, and\\nall lower Family members will be affected. Do you really want to separate from the selected character?"; + constexpr const char* FEE = "\\n\\nFee:%d Mesos"; + + std::string format(std::string text, std::initializer_list values) + { + for (int32_t value : values) + { + size_t at = text.find("%d"); + + if (at != std::string::npos) + text.replace(at, 2, std::to_string(value)); + } + + return text; + } + } + + std::string FamilyParting::text() const + { + std::string result = second_rep > 0 + ? format(junior ? JUNIOR_BOTH : SENIOR_BOTH, { rep, second_rep }) + : format(junior ? JUNIOR_ONE : SENIOR_ONE, { rep }); + + if (fee > 0) + result += format(FEE, { fee }); + + return result; + } + + FamilyParting FamilyChart::parting(const FamilyNode& other, bool junior) const + { + const FamilyNode& owner = *at(OWNER); + const FamilyNode& senior = junior ? owner : other; + const FamilyNode& below = junior ? other : owner; + + // The junior's level sets the Rep, the levels between the fee. + int32_t level = below.level; + int32_t rep = (level / 20 + 10) * level; + int32_t gap = std::max(0, senior.level - below.level); + + FamilyParting result; + result.id = other.id; + result.name = other.name; + result.junior = junior; + result.rep = 2 * rep; + result.second_rep = senior.senior >= 0 ? rep : 0; + result.fee = (gap + 2500) * gap; + + return result; + } + + std::optional FamilyChart::parting_at(size_t slot) const + { + const FamilyNode* owner = at(OWNER); + const FamilyNode* other = at(slot); + + if (!mine || !owner || !other) + return std::nullopt; + + if (owner->senior >= 0 && nodes[owner->senior].id == other->id) + return parting(*other, false); + + for (int32_t junior : owner->juniors) + if (nodes[junior].id == other->id) + return parting(*other, true); + + return std::nullopt; + } + + std::optional FamilyChart::parting_named(const std::string& name) const + { + const FamilyNode* owner = at(OWNER); + + if (!mine || !owner) + return std::nullopt; + + if (owner->senior >= 0 && nodes[owner->senior].name == name) + return parting(nodes[owner->senior], false); + + for (int32_t junior : owner->juniors) + if (nodes[junior].name == name) + return parting(nodes[junior], true); + + return std::nullopt; + } + void FamilyChart::arrange(const FamilyNode& self) { slots.fill(-1); diff --git a/HeavenClient/Character/Family.h b/HeavenClient/Character/Family.h index 095b6e47..39ea5270 100644 --- a/HeavenClient/Character/Family.h +++ b/HeavenClient/Character/Family.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -62,6 +63,24 @@ namespace ms std::vector juniors; }; + // Parting the owner of the player's own tree from their senior or one + // of their juniors (BtBye, report b048). + struct FamilyParting + { + int32_t id = 0; + std::string name; + // A junior (SEPARATE_FAMILY_BY_SENIOR) or the senior (BY_JUNIOR). + bool junior = false; + // The Rep the confirmation says each side loses, the second 0 when + // there is no one above, and the fee in mesos. + int32_t rep = 0; + int32_t second_rep = 0; + int32_t fee = 0; + + // The confirmation (0x80b385, 0x80b5d2). + std::string text() const; + }; + // A Family Tree (FAMILY_CHART_RESULT), laid out as the client does // (0x80a5f7): eleven boxes, four generations around the one viewed. struct FamilyChart @@ -91,12 +110,19 @@ namespace ms // Whether a member's name shows red: a junior of the player's tree // who passed their senior's level. bool passed_senior(size_t slot) const; + // Parting from the member in a box, or the one named (senior + // first), when they are the owner's senior or junior. + std::optional parting_at(size_t slot) const; + std::optional parting_named(const std::string& name) const; // Links the members as the client does: the first is the leader at // the top and nobody's senior, the one viewed is the owner, and the // others hang from their seniors. Without the owner in the packet, // the player's own tree gets one from `self`. void arrange(const FamilyNode& self); + + private: + FamilyParting parting(const FamilyNode& other, bool junior) const; }; // The player's family, as the server last described it. diff --git a/HeavenClient/IO/FamilyActions.cpp b/HeavenClient/IO/FamilyActions.cpp index 04d84564..6e789f45 100644 --- a/HeavenClient/IO/FamilyActions.cpp +++ b/HeavenClient/IO/FamilyActions.cpp @@ -19,8 +19,19 @@ namespace ms // StringPool 0x11e9 and 0x11f5. constexpr const char* ADD_PROMPT = "Enter the name of the character you wish to add as Junior. You must be in the same channel as well as map to add."; constexpr const char* CANNOT_ADD = "You cannot add this character as a Junior."; + // 0x11ea. + constexpr const char* PART_PROMPT = "Enter the name of the character you wish to delete."; // A character's name. constexpr size_t NAME_LIMIT = 12; + + // v83 keeps it on yes and never clears it (ctx+0x3858). + std::string parted; + + void deselect_tree() + { + if (auto tree = UI::get().get_element()) + tree->deselect(); + } } void toggle_window() @@ -63,6 +74,38 @@ namespace ms AddFamilyPacket(name).dispatch(); } + void prompt_parting(const FamilyChart& chart) + { + UI::get().emplace(PART_PROMPT, [chart](const std::string& name) + { + if (auto parting = chart.parting_named(name)) + confirm_parting(*parting); + }, NAME_LIMIT); + } + + void confirm_parting(const FamilyParting& parting) + { + UI::get().emplace(parting.text(), [parting](bool yes) + { + deselect_tree(); + + if (!yes || !Stage::get().get_player().get_family().loaded()) + return; + + if (parting.junior) + SeparateFamilyBySeniorPacket(parting.id).dispatch(); + else + SeparateFamilyByJuniorPacket().dispatch(); + + parted = parting.name; + }); + } + + const std::string& parted_from() + { + return parted; + } + bool can_add_junior() { const Family& family = Stage::get().get_player().get_family(); diff --git a/HeavenClient/IO/FamilyActions.h b/HeavenClient/IO/FamilyActions.h index de6a307f..beb80639 100644 --- a/HeavenClient/IO/FamilyActions.h +++ b/HeavenClient/IO/FamilyActions.h @@ -1,5 +1,7 @@ #pragma once +#include "../Character/Family.h" + #include namespace ms @@ -22,6 +24,14 @@ namespace ms // Whether the avatar menu has its Family row (0x9504ae): the // family is known and has room below the player. bool can_add_junior(); + // BtBye without a box selected (0x80b7ee): a name, matched to the + // owner's senior and then juniors; nothing when none is. + void prompt_parting(const FamilyChart& chart); + // The one Yes/No, and on yes the send (0x80b385, 0x80b5d2). The tree + // is deselected either way. + void confirm_parting(const FamilyParting& parting); + // Whom the player last parted from, for FAMILY_RESULT 1. + const std::string& parted_from(); // A family text in the chat log, as v83's type 0xc. void chat_line(const std::string& text); } diff --git a/HeavenClient/IO/UITypes/UIFamilyTree.cpp b/HeavenClient/IO/UITypes/UIFamilyTree.cpp index a0555e8f..34e7681b 100644 --- a/HeavenClient/IO/UITypes/UIFamilyTree.cpp +++ b/HeavenClient/IO/UITypes/UIFamilyTree.cpp @@ -134,6 +134,23 @@ namespace ms return chart.mine; } + void UIFamilyTree::deselect() + { + selected = -1; + } + + void UIFamilyTree::bye() + { + if (!chart.mine) + return; + + if (selected < 0) + return FamilyActions::prompt_parting(chart); + + if (auto parting = chart.parting_at(static_cast(selected))) + FamilyActions::confirm_parting(*parting); + } + void UIFamilyTree::lay_out() { const FamilyNode* leader = chart.at(FamilyChart::LEADER); @@ -187,9 +204,8 @@ namespace ms } buttons[JUNIOR]->set_state(chart.can_add_junior() ? Button::State::NORMAL : Button::State::DISABLED); - // Severing ties comes with the separation packets; until then the - // button stays off. - buttons[BYE]->set_state(Button::State::DISABLED); + // On whatever is selected, in the player's own tree only. + buttons[BYE]->set_state(chart.mine ? Button::State::NORMAL : Button::State::DISABLED); } void UIFamilyTree::draw(float inter) const @@ -405,10 +421,27 @@ namespace ms } } + // Only a box that lights up, with someone in it, and nothing else. + void UIFamilyTree::doubleclick(Point cursorpos) + { + if (!chart.mine) + return; + + int16_t slot = slot_at(cursorpos - position); + + if (!hoverable(slot) || !chart.at(slot)) + return; + + selected = slot; + bye(); + } + Button::State UIFamilyTree::button_pressed(uint16_t buttonid) { if (buttonid == JUNIOR) FamilyActions::prompt_junior(); + else if (buttonid == BYE) + bye(); return Button::State::NORMAL; } diff --git a/HeavenClient/IO/UITypes/UIFamilyTree.h b/HeavenClient/IO/UITypes/UIFamilyTree.h index 26d4118e..118c3e3b 100644 --- a/HeavenClient/IO/UITypes/UIFamilyTree.h +++ b/HeavenClient/IO/UITypes/UIFamilyTree.h @@ -27,6 +27,7 @@ namespace ms void draw(float inter) const override; Cursor::State send_cursor(bool clicked, Point cursorpos) override; + void doubleclick(Point cursorpos) override; void remove_cursor() override; void send_key(int32_t keycode, bool pressed, bool escape) override; @@ -37,6 +38,8 @@ namespace ms // A relative logged in or out (FAMILY_NOTIFY_LOGIN_OR_LOGOUT). void set_online(const std::string& name, bool online); bool is_mine() const; + // The parting asked about is answered, either way. + void deselect(); protected: Button::State button_pressed(uint16_t buttonid) override; @@ -49,6 +52,8 @@ namespace ms bool hoverable(int16_t slot) const; std::string member_tooltip(size_t slot) const; void clear_tooltip(); + // BtBye (0x80b7cc): the box selected, or a name asked for. + void bye(); FamilyChart chart; diff --git a/HeavenClient/IO/UITypes/UIInviteCard.cpp b/HeavenClient/IO/UITypes/UIInviteCard.cpp index 3aa58ea4..67335bc8 100644 --- a/HeavenClient/IO/UITypes/UIInviteCard.cpp +++ b/HeavenClient/IO/UITypes/UIInviteCard.cpp @@ -51,7 +51,7 @@ namespace ms { "icon5", "Party invite", Point(26, 4) }, { "icon5", "Guild invite", Point(26, 4), true }, { "icon5", "Guild Alliance Invitation.", Point(26, 4), true, true }, - // v83 writes it as the alliance's (FUN_00524298). + // "from ''" first, as the guild's (0x5221fa, report b047). { "icon8", "It's a Family Invitation.", Point(26, 4), true } }; diff --git a/HeavenClient/Net/Handlers/FamilyHandlers.cpp b/HeavenClient/Net/Handlers/FamilyHandlers.cpp index b8869c53..05f47f3d 100644 --- a/HeavenClient/Net/Handlers/FamilyHandlers.cpp +++ b/HeavenClient/Net/Handlers/FamilyHandlers.cpp @@ -21,12 +21,13 @@ namespace ms { // FAMILY_RESULT's codes (0xa345fb, report b039 1.3) with their // StringPool texts. 0x4b and 0x4c go to the chat log; the others - // are OK dialogs. Code 1, the end of a relationship the player - // severed, comes with separation. + // are OK dialogs. Code 1 (0x120a) names whom the player parted + // from, by the name kept when they said yes (report b048). const char* result_text(int32_t code) { switch (code) { + case 1: return "You have severed ties with %s.\\nYour family relationship has ended."; case 0x40: return "You cannot add this character as a Junior."; case 0x41: return "The name you entered is incorrect or he/she is currently not logged in."; case 0x42: return "You belong to the same family."; @@ -49,6 +50,16 @@ namespace ms } } + std::string with_name(std::string text, const std::string& name) + { + size_t at = text.find("%s"); + + if (at != std::string::npos) + text.replace(at, 2, name); + + return text; + } + std::string with_number(std::string text, int32_t value) { size_t at = text.find("%d"); @@ -127,6 +138,8 @@ namespace ms if (code == 0x4b || code == 0x4c) FamilyActions::chat_line(text); + else if (code == 1) + ok(with_name(text, FamilyActions::parted_from())); else ok(with_number(text, arg)); } diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 3799bac3..65471678 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -152,6 +152,8 @@ namespace ms OPEN_FAMILY_PEDIGREE = 145, OPEN_FAMILY = 146, ADD_FAMILY = 147, + SEPARATE_FAMILY_BY_SENIOR = 148, + SEPARATE_FAMILY_BY_JUNIOR = 149, ACCEPT_FAMILY = 150, USE_FAMILY = 151, CHANGE_FAMILY_MESSAGE = 152, diff --git a/HeavenClient/Net/Packets/FamilyPackets.h b/HeavenClient/Net/Packets/FamilyPackets.h index 132e87ae..1d8c315a 100644 --- a/HeavenClient/Net/Packets/FamilyPackets.h +++ b/HeavenClient/Net/Packets/FamilyPackets.h @@ -34,6 +34,24 @@ namespace ms } }; + // Parts the player from one of their juniors, by id + // (SEPARATE_FAMILY_BY_SENIOR, report b048). + class SeparateFamilyBySeniorPacket : public OutPacket + { + public: + SeparateFamilyBySeniorPacket(int32_t junior) : OutPacket(OutPacket::Opcode::SEPARATE_FAMILY_BY_SENIOR) + { + write_int(junior); + } + }; + + // Parts the player from their senior (SEPARATE_FAMILY_BY_JUNIOR). + class SeparateFamilyByJuniorPacket : public OutPacket + { + public: + SeparateFamilyByJuniorPacket() : OutPacket(OutPacket::Opcode::SEPARATE_FAMILY_BY_JUNIOR) {} + }; + // The answer to a senior's invitation (ACCEPT_FAMILY). class AcceptFamilyPacket : public OutPacket { diff --git a/HeavenClient/tests/family.cpp b/HeavenClient/tests/family.cpp index 7d070710..14fe572e 100644 --- a/HeavenClient/tests/family.cpp +++ b/HeavenClient/tests/family.cpp @@ -214,6 +214,52 @@ void four_generations_around_the_one_viewed() require(c.count(-1) == 10 && c.count(42) == 0, "counts"); } +void parting_names_the_rep_and_the_fee() +{ + // V (70) under S1 (90) under S2 (100); juniors J1 (50) and J2 (80). + FamilyChart c = chart(5, 5, { + { 1, 0, 120, "L" }, + { 3, 2, 100, "S2" }, + { 4, 3, 90, "S1" }, + { 5, 4, 70, "V" }, + { 7, 5, 50, "J1" }, + { 8, 5, 80, "J2" }, + }); + + // Parting from a junior: 2X for the owner, X for their senior, by the + // junior's level, and the fee by the levels between. + auto j1 = c.parting_at(5); + require(j1 && j1->junior && j1->id == 7, "J1 is a junior"); + require(j1->rep == 1200 && j1->second_rep == 600, "X(50) = 600"); + require(j1->fee == 2520 * 20, "20 levels between"); + require(j1->text().find("1200 Reputation Points for yourself and 600") == 0, "both figures"); + require(j1->text().find("Fee:50400 Mesos") != std::string::npos, "the fee"); + + // One who passed the owner costs no fee. + auto j2 = c.parting_named("J2"); + require(j2 && j2->junior && j2->fee == 0, "no fee upwards"); + require(j2->text().find("Fee") == std::string::npos, "no fee line"); + + // Parting from the senior goes by the owner's level. + auto s1 = c.parting_named("S1"); + require(s1 && !s1->junior, "S1 is the senior"); + require(s1->rep == 2 * 910 && s1->second_rep == 910, "X(70) = 910"); + require(s1->fee == 2520 * 20, "S1 is 20 above"); + require(s1->text().find("for the Senior and") != std::string::npos, "the 2nd Senior too"); + + require(!c.parting_named("S2") && !c.parting_named("j1") && !c.parting_at(4), "only the senior and juniors, by exact name"); + require(!c.parting_at(0), "not the leader"); + + // A senior who leads has nobody above. + FamilyChart junior = chart(2, 2, { { 1, 0, 30, "A" }, { 1, 0, 30, "A" }, { 2, 1, 15, "B" } }); + auto a = junior.parting_at(FamilyChart::SENIOR); + require(a && !a->junior && a->second_rep == 0 && a->rep == 2 * 150, "X(15) = 150"); + require(a->text().find("Reputations for the Senior") == 4, "the one figure"); + + FamilyChart theirs = chart(2, 1, { { 1, 0, 30, "A" }, { 1, 0, 30, "A" }, { 2, 1, 15, "B" } }); + require(!theirs.parting_at(FamilyChart::SENIOR), "not from another's tree"); +} + int main() { try @@ -223,6 +269,7 @@ int main() the_first_entry_is_nobodys_senior(); two_or_fewer_entries_leave_the_top_empty(); four_generations_around_the_one_viewed(); + parting_names_the_rep_and_the_fee(); } catch (const std::exception& error) { diff --git a/agent_docs/families.md b/agent_docs/families.md index d274298b..ff6478a6 100644 --- a/agent_docs/families.md +++ b/agent_docs/families.md @@ -14,8 +14,9 @@ who has ever been in a family: - `senior_id` points at the character's senior; the juniors are the rows that point at them. - It also holds the rep counters: current, total, today, and rep recorded - to the senior. Nothing earns rep yet. -- `precepts` matters only on the leader's row. + to the senior. Nothing earns rep yet; only separating takes it away. +- `precepts` matters only on the leader's row. A junior who leaves keeps an + empty one until they gain juniors of their own. `game::family` holds the rules and the types: @@ -23,6 +24,9 @@ who has ever been in a family: - A junior must be level 10 or above, below the senior's level, and at most 20 levels below the senior (`check_levels`). - A family may span at most 1000 generations. +- The precepts are at most 200 characters (`MAX_PRECEPTS`). +- Separating costs Rep and mesos (`separation_rep`, `separation_fee`; see + below). - `Refusal` maps each refusal to the `FAMILY_RESULT` code that picks the client's line. @@ -86,6 +90,33 @@ packets into `FamilyRequest`s, which the client actor - When INFO arrives while the player's own tree is open, the client asks for the chart again. +- **`CHANGE_FAMILY_MESSAGE` (text):** only the leader (a character with + juniors and no senior) may set the precepts (`db::family::set_precepts`). + The whole family then gets INFO again. +- **Separating:** `SEPARATE_FAMILY_BY_SENIOR` (0x94, the junior's id) is + sent when the player cuts off one of their juniors, and + `SEPARATE_FAMILY_BY_JUNIOR` (0x95, empty) when they leave their senior + (report b048 corrects b039 on which is which). + 1. The actor names the pair from `Player.family`. A junior who isn't the + player's, or 0x95 without a senior, is ignored. + 2. The directory adds the levels of whichever of the two is online + (`DirectoryMessage::FamilySeparate`), and the player's actor runs + `db::family::separate` (`ServerMessage::Separate`). It reads an offline + character's level from the database. + 3. In one transaction that locks both rows and the senior's senior, it + checks that they are still senior and junior and works out the fee: + `(d + 2500) * d` mesos, where `d` is how many levels the senior is + above the junior. The player pays, whichever side they are on. If the + two are no longer related, the player just gets INFO again. If they can't, `FAMILY_RESULT` 0x50 + (leaving a senior) or 0x51 (cutting a junior) tells them how much they + need. + 4. With `X = (L / 20 + 10) * L` for the junior's level `L`, the senior + loses `2X` Rep and the senior's own senior `X`, never below 0. The + junior's link and the Rep recorded to the senior are cleared. + 5. The player gets their mesos as a stat update, then `FAMILY_RESULT` 1, + which the client shows with the name it remembered. Everyone in the old + family gets INFO again. + ### Login notices Relatives (the senior and the juniors) get `FAMILY_NOTIFY_LOGIN_OR_LOGOUT` @@ -96,9 +127,7 @@ when a member enters or leaves the game. A channel change is held for These are not implemented yet: -- separating from a senior or a junior; - entitlements (`USE_FAMILY`) and the summon; -- changing the precepts; - earning rep. ## Client @@ -111,8 +140,8 @@ b045 (how they open). tree's 11 boxes as the v83 decoder does. `tests/family.cpp` covers the parser and the arrangement. - **Packets:** `Net/Handlers/FamilyHandlers.cpp` handles receive opcodes - 94-102. `Net/Packets/FamilyPackets.h` builds the sends; separation (0x94, - 0x95) and the summon answer (0x99) are not built yet. + 94-102. `Net/Packets/FamilyPackets.h` builds the sends; the summon answer + (0x99) is not built yet. - **Family window (`UIFamily`):** the Family key (F by default) and BtFamily on the player's own info open it, and each opening sends `OPEN_FAMILY`. It shows the family's name, the scrolling precepts, juniors, Rep and the @@ -128,7 +157,15 @@ b045 (how they open). Texts are centred in their boxes as v83 does it, halving the room left as unsigned, so one wider than its box is never seen: the empty boxes' "(You do not have family members.)" shows nothing. - BtBye stays disabled until separation exists. +- **Separating (report b048):** BtBye is enabled on the player's own tree. + With the senior or a junior selected it goes straight to the Yes/No; + otherwise it asks for a name and matches it exactly against the senior, + then the juniors, saying nothing if none match. Double-clicking a filled + senior or junior box does the same as BtBye. There is a single Yes/No + (`FamilyParting::text`). It gives the Rep lost and, when there is one, the + fee, from the levels on the chart. Either answer clears the selection. + Yes sends 0x94 or 0x95 and remembers the name for `FAMILY_RESULT` 1's "You + have severed ties with %s." The tree stays open and refreshes on INFO. - **Adding a junior:** the name prompt, or the avatar menu's Family row (shown once INFO says there is room), sends `ADD_FAMILY`. - **Being asked:** `FAMILY_JOIN_REQUEST` shows a Family card, unless one is diff --git a/db/src/family.rs b/db/src/family.rs index 3b75df6f..479d08e5 100644 --- a/db/src/family.rs +++ b/db/src/family.rs @@ -5,7 +5,8 @@ use crate::schema::{characters, family_members}; use diesel::prelude::*; use game::family::{ - ChartMember, FamilyChart, FamilyInfo, Refusal, Relatives, MAX_GENERATIONS, MAX_JUNIORS, + separation_fee, separation_rep, ChartMember, FamilyChart, FamilyInfo, Refusal, Relatives, + MAX_GENERATIONS, MAX_JUNIORS, MAX_PRECEPTS, }; diesel::define_sql_function!(fn lower(x: diesel::sql_types::Text) -> diesel::sql_types::Text); @@ -264,3 +265,100 @@ pub fn add_junior(senior: i32, junior: i32) -> crate::Result, Re Ok(Ok(changed)) })?) } + +/// The leader's new precepts. Anyone else, or a leader of no one, changes +/// nothing; otherwise everyone in the family, who see them in the window. +pub fn set_precepts(leader: i32, precepts: &str) -> crate::Result>> { + if precepts.chars().count() > MAX_PRECEPTS { + return Ok(None); + } + let mut conn = crate::connection()?; + Ok(conn.transaction(|conn| -> QueryResult<_> { + if senior_of(conn, leader)?.is_some() || juniors(conn, leader)?.is_empty() { + return Ok(None); + } + diesel::update(family_members::table.find(leader)) + .set(family_members::precepts.eq(precepts)) + .execute(conn)?; + Ok(Some(descendants(conn, leader)?.0)) + })?) +} + +/// How parting a senior from a junior went. +#[derive(Debug, PartialEq, Eq)] +pub enum Separation { + /// They aren't senior and junior (any more). + NotRelated, + /// The asker hasn't the fee. + Short { fee: i32 }, + /// Parted, for `fee`. `ids` is the family as it was, whose windows all + /// change. + Parted { fee: i32, ids: Vec }, +} + +/// Part `junior`, with their branch, from `senior`, for the asker, who has +/// `meso`. Levels are those online now where given, else the saved ones. +/// The senior loses twice `separation_rep` and their senior once; the +/// junior leads a family of their own, with no precepts yet (report b048). +pub fn separate( + senior: i32, + junior: i32, + levels: (Option, Option), + meso: i32, +) -> crate::Result { + let mut conn = crate::connection()?; + Ok(conn.transaction(|conn| -> QueryResult<_> { + // The senior's senior loses Rep too: lock all three in id order, so + // as not to deadlock with a separation or an add one level up. + let above = senior_of(conn, senior)?; + let mut rows: Vec = [senior, junior].into_iter().chain(above).collect(); + rows.sort_unstable(); + family_members::table + .filter(family_members::character_id.eq_any(rows)) + .order(family_members::character_id) + .select(family_members::character_id) + .for_update() + .load::(conn)?; + if senior_of(conn, junior)? != Some(senior) || senior_of(conn, senior)? != above { + return Ok(Separation::NotRelated); + } + let mut level = |id: i32, known: Option| -> QueryResult { + match known { + Some(level) => Ok(level), + None => characters::table + .find(id) + .select(characters::level) + .first::(conn) + .map(|level| level.clamp(0, 255) as u8), + } + }; + let (senior_level, junior_level) = (level(senior, levels.0)?, level(junior, levels.1)?); + let fee = separation_fee(senior_level, junior_level); + if meso < fee { + return Ok(Separation::Short { fee }); + } + let seniors = seniors(conn, senior)?; + let leader = seniors.last().copied().unwrap_or(senior); + let (ids, _) = descendants(conn, leader)?; + + let rep = separation_rep(junior_level); + let losses = std::iter::once((senior, 2 * rep)).chain(above.map(|s| (s, rep))); + for (id, loss) in losses { + let reputation = family_members::table + .find(id) + .select(family_members::reputation) + .first::(conn)?; + diesel::update(family_members::table.find(id)) + .set(family_members::reputation.eq((reputation - loss).max(0))) + .execute(conn)?; + } + diesel::update(family_members::table.find(junior)) + .set(( + family_members::senior_id.eq(None::), + family_members::senior_reputation.eq(0), + family_members::precepts.eq(""), + )) + .execute(conn)?; + Ok(Separation::Parted { fee, ids }) + })?) +} diff --git a/game/src/family.rs b/game/src/family.rs index ea03902c..2b82a8f7 100644 --- a/game/src/family.rs +++ b/game/src/family.rs @@ -11,6 +11,8 @@ pub const MIN_JUNIOR_LEVEL: u8 = 10; pub const MAX_LEVEL_GAP: u8 = 20; /// The most generations a family may span, top to bottom (0x1202). pub const MAX_GENERATIONS: usize = 1000; +/// The longest precepts the client lets the leader type (b039). +pub const MAX_PRECEPTS: usize = 200; /// Why a junior wasn't added: FAMILY_RESULT's code, which picks the /// client's line. @@ -68,6 +70,21 @@ pub fn check_levels(senior: u8, junior: u8) -> Result<(), Refusal> { } } +/// What parting a senior of level `senior` from a junior of level `junior` +/// costs whoever asks, in mesos: `(d + 2500) * d` for the levels between +/// them, as the client's confirmation shows it (report b048). +pub fn separation_fee(senior: u8, junior: u8) -> i32 { + let gap = i32::from(senior.saturating_sub(junior)); + (gap + 2500) * gap +} + +/// The Rep that parting from a junior of level `junior` takes from the +/// senior's own senior; the senior loses twice it (report b048). +pub fn separation_rep(junior: u8) -> i32 { + let level = i32::from(junior); + (level / 20 + 10) * level +} + /// A character's senior and juniors, by character id; both empty out of a /// family. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -144,4 +161,15 @@ mod tests { assert_eq!(check_levels(50, 30), Ok(())); assert_eq!(check_levels(51, 30), Err(Refusal::LevelGap)); } + + #[test] + fn separating_costs_by_the_gap_and_the_juniors_level() { + assert_eq!(separation_fee(60, 50), 25_100); + assert_eq!(separation_fee(50, 50), 0); + // A junior who has outgrown the senior costs nothing. + assert_eq!(separation_fee(50, 60), 0); + assert_eq!(separation_rep(50), 600); + assert_eq!(separation_rep(19), 190); + assert_eq!(separation_rep(20), 220); + } } diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index 1bab67d5..e5d86283 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -414,6 +414,17 @@ pub async fn set_meso(character_id: i32, meso: i32) -> Result<(), HarnessError> .await } +/// Give `character_id`, who is in a family, `reputation` Rep to spend. +pub async fn set_family_reputation(character_id: i32, reputation: i32) -> Result<(), HarnessError> { + change_one_row("set family reputation", move |conn| { + diesel::sql_query("UPDATE family_members SET reputation = $2 WHERE character_id = $1") + .bind::(character_id) + .bind::(reputation) + .execute(conn) + }) + .await +} + /// Start quest `quest_id` for the logged-out character `character_id`. pub async fn start_quest(character_id: i32, quest_id: i16) -> Result<(), HarnessError> { change_one_row("start quest", move |conn| { diff --git a/integration-harness/tests/families.rs b/integration-harness/tests/families.rs index 6f5737b5..15912d06 100644 --- a/integration-harness/tests/families.rs +++ b/integration-harness/tests/families.rs @@ -4,9 +4,13 @@ //! may say no or yes (ACCEPT_FAMILY). The server refuses a name not online, //! another map, too low a level, a junior who has a senior, and a senior of //! the same family. The Family Tree (OPEN_FAMILY_PEDIGREE) shows who is in -//! it and online, and relatives hear who logs in or out. +//! it and online, and relatives hear who logs in or out. The leader alone +//! may change the precepts, which the whole family sees. Parting from a +//! junior or a senior (SEPARATE_FAMILY_BY_SENIOR/JUNIOR, report b048) costs +//! the asker a fee by the levels between them, and the seniors Rep by the +//! junior's level. -use integration_harness::database::set_job; +use integration_harness::database::{set_family_reputation, set_job, set_meso}; use integration_harness::preconditions::load_harness_config_or_fail; use integration_harness::trace::{read_for, send, until, warp, Trace}; use integration_harness::{login_to_world_session, relogin, HarnessConfig, WorldSession}; @@ -48,13 +52,17 @@ fn str_bytes(text: &str) -> Vec { /// FAMILY_INFO_RESULT with no rep yet. fn info(juniors: u16, leader: i32, name: &str) -> (i16, Vec) { - let mut body = [0i32; 3].map(i32::to_le_bytes).concat(); + info_with(0, juniors, leader, name, "") +} + +fn info_with(rep: i32, juniors: u16, leader: i32, name: &str, precepts: &str) -> (i16, Vec) { + let mut body = [rep, 0, 0].map(i32::to_le_bytes).concat(); body.extend(juniors.to_le_bytes()); body.extend(2u16.to_le_bytes()); body.extend(0u16.to_le_bytes()); body.extend(leader.to_le_bytes()); body.extend(str_bytes(name)); - body.extend(str_bytes("")); + body.extend(str_bytes(precepts)); body.extend(0i32.to_le_bytes()); (SendOpcode::FamilyInfo as i16, body) } @@ -216,3 +224,132 @@ async fn a_senior_takes_a_junior_on_their_map_who_says_yes() { let entered = family(&mut b, 2).await; assert_eq!(entered[0], info(0, a_id, &an)); } + +/// `senior` asks `junior`, both on one map, who says yes. +async fn take_junior( + senior: &mut WorldSession, + senior_name: &str, + junior: &mut WorldSession, + junior_name: &str, +) { + send( + &mut senior.connection, + request(RecvOpcode::AddFamily, junior_name), + ) + .await; + family(junior, 1).await; + send( + &mut junior.connection, + answer(senior.character_id, senior_name, true), + ) + .await; + family(senior, 2).await; + family(junior, 2).await; +} + +fn result(code: i32, arg: i32) -> (i16, Vec) { + let body = [code, arg].map(i32::to_le_bytes).concat(); + (SendOpcode::FamilyResult as i16, body) +} + +#[tokio::test] +async fn the_leader_sets_the_precepts_and_parting_costs_rep_and_mesos() { + 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 a_id = new_character(&a_config, 50).await; + let b_id = new_character(&b_config, 35).await; + let c_id = new_character(&c_config, 20).await; + set_meso(a_id, 100_000).await.expect("meso"); + set_meso(b_id, 0).await.expect("meso"); + set_meso(c_id, 100_000).await.expect("meso"); + let (an, bn, cn) = ( + a_config.character_name.clone(), + b_config.character_name.clone(), + c_config.character_name.clone(), + ); + let (mut a, _) = enter(&a_config, LITH_HARBOR).await; + let (mut b, _) = enter(&b_config, LITH_HARBOR).await; + let (mut c, _) = enter(&c_config, LITH_HARBOR).await; + take_junior(&mut a, &an, &mut b, &bn).await; + take_junior(&mut b, &bn, &mut c, &cn).await; + set_family_reputation(a_id, 5000).await.expect("rep"); + set_family_reputation(b_id, 5000).await.expect("rep"); + + // Only the leader's precepts count, and everyone sees them. + let precepts = |text: &str| { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::ChangeFamilyMessage as i16) + .unwrap(); + p.write_str_with_length(text).unwrap(); + p + }; + send(&mut b.connection, precepts("Not mine to say")).await; + send(&mut a.connection, precepts("Be kind")).await; + assert_eq!( + family(&mut a, 1).await, + [info_with(5000, 1, a_id, &an, "Be kind")] + ); + assert_eq!( + family(&mut b, 1).await, + [info_with(5000, 1, a_id, &an, "Be kind")] + ); + assert_eq!( + family(&mut c, 1).await, + [info_with(0, 0, a_id, &an, "Be kind")] + ); + + // B can't pay to part from C: 15 levels between them. + let mut cut = Packet::new_empty(); + cut.write_short(RecvOpcode::SeparateFamilyBySenior as i16) + .unwrap(); + cut.write_int(c_id).unwrap(); + send(&mut b.connection, cut).await; + assert_eq!(family(&mut b, 1).await, [result(0x51, (15 + 2500) * 15)]); + + // C parts from B and pays. B loses 2 * (20 / 20 + 10) * 20, A half. + let mut leave = Packet::new_empty(); + leave + .write_short(RecvOpcode::SeparateFamilyByJunior as i16) + .unwrap(); + let mut ct = Trace::default(); + send(&mut c.connection, leave).await; + until(&mut c.connection, &mut ct, |t| t.family.len() >= 2).await; + assert_eq!(ct.family, [result(1, 0), info(0, 0, "")]); + let meso = ct + .stat_updates + .iter() + .find_map(|u| u.get(&integration_harness::trace::stat::MESO)); + assert_eq!(meso, Some(&(100_000 - 37_725))); + assert_eq!( + family(&mut b, 1).await, + [info_with(5000 - 440, 0, a_id, &an, "Be kind")] + ); + assert_eq!( + family(&mut a, 1).await, + [info_with(5000 - 220, 1, a_id, &an, "Be kind")] + ); + + // A cuts off B, with no senior above to lose half. Neither is left in + // a family. + let mut cut = Packet::new_empty(); + cut.write_short(RecvOpcode::SeparateFamilyBySenior as i16) + .unwrap(); + cut.write_int(b_id).unwrap(); + let mut at = Trace::default(); + send(&mut a.connection, cut).await; + until(&mut a.connection, &mut at, |t| t.family.len() >= 2).await; + let a_rep = 5000 - 220 - 2 * (35 / 20 + 10) * 35; + assert_eq!(at.family, [result(1, 0), info_with(a_rep, 0, 0, "", "")]); + let meso = at + .stat_updates + .iter() + .find_map(|u| u.get(&integration_harness::trace::stat::MESO)); + assert_eq!(meso, Some(&(100_000 - 37_725))); + assert_eq!( + family(&mut b, 1).await, + [info_with(5000 - 440, 0, 0, "", "")] + ); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 967edece..0bc7c135 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -301,6 +301,10 @@ pub enum FamilyRequest { Add { name: String }, /// Answer `inviter`'s request to be their junior. Answer { inviter: ClientId, accept: bool }, + /// The leader's new precepts. + Precepts { precepts: String }, + /// Part from the junior with this id, or from the player's senior. + Separate { junior: Option }, } /// A guild operation, carried out by the client's actor. @@ -849,7 +853,19 @@ fn world_routes() -> &'static HashMap { ), (Op::OpenFamily, InGame(&world::OpenFamilyHandler)), (Op::AddFamily, InGame(&world::AddFamilyHandler)), + ( + Op::SeparateFamilyBySenior, + InGame(&world::SeparateFamilyBySeniorHandler), + ), + ( + Op::SeparateFamilyByJunior, + InGame(&world::SeparateFamilyByJuniorHandler), + ), (Op::AcceptFamily, InGame(&world::AcceptFamilyHandler)), + ( + Op::ChangeFamilyMessage, + InGame(&world::ChangeFamilyMessageHandler), + ), ( Op::AllianceOperation, InGame(&world::AllianceOperationHandler), diff --git a/net/src/packet/build/world/family.rs b/net/src/packet/build/world/family.rs index 7d0de6d4..5bdf9957 100644 --- a/net/src/packet/build/world/family.rs +++ b/net/src/packet/build/world/family.rs @@ -69,6 +69,24 @@ pub fn build_privilege_list() -> Result { Ok(packet) } +/// FAMILY_RESULT 1: the asker parted, and the client says from whom, by +/// the name it kept (report b048). +pub fn build_separated() -> Result { + let mut packet = op(SendOpcode::FamilyResult)?; + packet.write_int(1)?; + packet.write_int(0)?; + Ok(packet) +} + +/// FAMILY_RESULT 0x51 or 0x50: parting from a junior, or from the senior, +/// costs `fee`, more than the asker has. +pub fn build_separation_fee(from_junior: bool, fee: i32) -> Result { + let mut packet = op(SendOpcode::FamilyResult)?; + packet.write_int(if from_junior { 0x51 } else { 0x50 })?; + packet.write_int(fee)?; + Ok(packet) +} + /// FAMILY_RESULT with a refusal's line. pub fn build_family_refusal(refusal: Refusal) -> Result { let mut packet = op(SendOpcode::FamilyResult)?; diff --git a/net/src/packet/handle/world/family.rs b/net/src/packet/handle/world/family.rs index a724209f..bdb5729a 100644 --- a/net/src/packet/handle/world/family.rs +++ b/net/src/packet/handle/world/family.rs @@ -60,6 +60,42 @@ impl WorldHandler for AcceptFamilyHandler { } } +/// `SEPARATE_FAMILY_BY_SENIOR`: the player parts from a junior, by id +/// (report b048). +pub struct SeparateFamilyBySeniorHandler; + +impl WorldHandler for SeparateFamilyBySeniorHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let junior = reader.read_int()?; + request(FamilyRequest::Separate { + junior: Some(junior), + }) + } +} + +/// `SEPARATE_FAMILY_BY_JUNIOR`: the player parts from their senior. +pub struct SeparateFamilyByJuniorHandler; + +impl WorldHandler for SeparateFamilyByJuniorHandler { + fn handle(&self, _packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + request(FamilyRequest::Separate { junior: None }) + } +} + +/// `CHANGE_FAMILY_MESSAGE`: the leader's new precepts. +pub struct ChangeFamilyMessageHandler; + +impl WorldHandler for ChangeFamilyMessageHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let precepts = reader.read_str_with_length()?; + request(FamilyRequest::Precepts { precepts }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -119,5 +155,26 @@ mod tests { accept: true } ); + let mut separate = packet(RecvOpcode::SeparateFamilyBySenior); + separate.write_int(9).unwrap(); + assert_eq!( + handle(&SeparateFamilyBySeniorHandler, separate), + FamilyRequest::Separate { junior: Some(9) } + ); + assert_eq!( + handle( + &SeparateFamilyByJuniorHandler, + packet(RecvOpcode::SeparateFamilyByJunior) + ), + FamilyRequest::Separate { junior: None } + ); + let mut precepts = packet(RecvOpcode::ChangeFamilyMessage); + precepts.write_str_with_length("Be kind").unwrap(); + assert_eq!( + handle(&ChangeFamilyMessageHandler, precepts), + FamilyRequest::Precepts { + precepts: "Be kind".into() + } + ); } } diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 35204894..726294c3 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -93,7 +93,8 @@ pub use self::duey::DueyActionHandler; pub use self::emote::FaceExpressionHandler; pub use self::fame::{GiveFameHandler, MIN_FAME_LEVEL}; pub use self::family::{ - AcceptFamilyHandler, AddFamilyHandler, OpenFamilyHandler, OpenFamilyPedigreeHandler, + AcceptFamilyHandler, AddFamilyHandler, ChangeFamilyMessageHandler, OpenFamilyHandler, + OpenFamilyPedigreeHandler, SeparateFamilyByJuniorHandler, SeparateFamilyBySeniorHandler, }; pub use self::guild::{DenyGuildRequestHandler, GuildOperationHandler}; pub use self::guild_bbs::GuildBbsHandler; diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index e6fb7ee3..7e3f967a 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -63,8 +63,14 @@ pub enum RecvOpcode { OpenFamily = 0x92, /// Add Junior, by name. AddFamily = 0x93, + /// A senior parting from a junior, by id (report b048). + SeparateFamilyBySenior = 0x94, + /// A junior parting from their senior. + SeparateFamilyByJunior = 0x95, /// An answer to a junior request (the card, or the client itself). AcceptFamily = 0x96, + /// The leader's new precepts, from the Family window. + ChangeFamilyMessage = 0x98, /// The guild bulletin board (FUN_008166f6 and its siblings). GuildBbs = 0x9B, FaceExpression = 0x33, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index ed1d8f70..43d409ae 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -1225,6 +1225,11 @@ impl ClientActor { ServerMessage::GuildChanged => self.guild_changed().await?, ServerMessage::AddJunior { junior, name } => self.add_junior(junior, name).await?, ServerMessage::FamilyChanged => self.family_changed().await?, + ServerMessage::Separate { + senior, + junior, + levels, + } => self.separate(senior, junior, levels).await?, ServerMessage::JoinAlliance { alliance_id } => self.join_alliance(alliance_id).await?, ServerMessage::PartySkill { used, diff --git a/runtime/src/actor/client/family.rs b/runtime/src/actor/client/family.rs index 848004a0..7a7407ba 100644 --- a/runtime/src/actor/client/family.rs +++ b/runtime/src/actor/client/family.rs @@ -3,11 +3,13 @@ use super::ClientActor; use crate::{db::spawn_db, error::RuntimeError, message::DirectoryMessage}; +use db::family::Separation; use game::family::Refusal; use net::packet::build::world::family::{ build_family_info, build_family_refusal, build_join_accepted, build_join_result, - build_privilege_list, + build_privilege_list, build_separated, build_separation_fee, }; +use net::packet::build::world::stat::{self, StatUpdate}; use net::{ClientId, FamilyRequest}; use tracing::warn; @@ -58,7 +60,80 @@ impl ClientActor { }) .await } + FamilyRequest::Precepts { precepts } => { + let cid = self.client_id; + let ids = spawn_db(move || db::family::set_precepts(cid, &precepts)).await?; + match ids { + Some(ids) => { + self.send_to_directory(DirectoryMessage::FamilyChanged { ids }) + .await + } + None => Ok(()), + } + } + FamilyRequest::Separate { junior } => self.ask_separate(junior).await, + } + } + + /// Part from `junior`, or from the senior. The directory has the levels + /// of those online, which the client worked the fee out from. + async fn ask_separate(&mut self, junior: Option) -> Result<(), RuntimeError> { + let cid = self.client_id; + let family = &self.in_game()?.player.family; + let (senior, junior) = match (junior, family.senior) { + (Some(junior), _) if family.juniors.contains(&junior) => (cid, junior), + (None, Some(senior)) => (senior, cid), + _ => return Ok(()), + }; + self.send_to_directory(DirectoryMessage::FamilySeparate { + from: cid, + senior, + junior, + }) + .await + } + + /// Part `junior` from `senior` for the player, who pays. Only they hear + /// how it went; the whole family as it was gets INFO again. + pub(super) async fn separate( + &mut self, + senior: ClientId, + junior: ClientId, + levels: (Option, Option), + ) -> Result<(), RuntimeError> { + let meso = self.in_game()?.player.meso; + let separation = spawn_db(move || db::family::separate(senior, junior, levels, meso)).await; + let (fee, ids) = match separation { + // The client's tree is out of date, or the rows were busy: show + // the family as it is. + Ok(Separation::NotRelated) => return self.family_changed().await, + Err(error) => { + warn!(senior, junior, %error, "Failed to separate"); + return self.family_changed().await; + } + Ok(Separation::Short { fee }) => { + let from_junior = senior == self.client_id; + return self + .send(&mut build_separation_fee(from_junior, fee)?) + .await; + } + Ok(Separation::Parted { fee, ids }) => (fee, ids), + }; + if fee > 0 { + let player = &mut self.in_game_mut()?.player; + player.meso -= fee; + player.mark_dirty(); + let meso = player.meso; + self.flush().await?; + self.send(&mut stat::build_stat_update( + false, + &[StatUpdate::Meso(meso)], + )?) + .await?; } + self.send(&mut build_separated()?).await?; + self.send_to_directory(DirectoryMessage::FamilyChanged { ids }) + .await } /// Ask the character named `name` to be the player's junior. The diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index 02f2a62a..4d897366 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -313,6 +313,22 @@ impl WorldDirectory { accept, } => self.family_answer(from, inviter, accept, Instant::now()), DirectoryMessage::FamilyChart { owner, chart } => self.send_family_chart(owner, chart), + DirectoryMessage::FamilySeparate { + from, + senior, + junior, + } => { + let level = |id| self.clients.get(&id).map(|entry| entry.presence.level); + let levels = (level(senior), level(junior)); + self.tell( + from, + ServerMessage::Separate { + senior, + junior, + levels, + }, + ); + } DirectoryMessage::FamilyChanged { ids } => { for id in ids { self.tell(id, ServerMessage::FamilyChanged); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 72b403f7..dfc21d3a 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -99,6 +99,13 @@ pub enum ServerMessage { }, /// The player's family changed; read it again. FamilyChanged, + /// Part `junior` from `senior` for the player, at these levels where + /// they are online. + Separate { + senior: ClientId, + junior: ClientId, + levels: (Option, Option), + }, /// The invitation the player's guild had to `alliance_id` stands; join /// it. JoinAlliance { @@ -607,6 +614,13 @@ pub enum DirectoryMessage { inviter: ClientId, accept: bool, }, + /// `from` would part `junior` from `senior`; their levels now, for the + /// fee. + FamilySeparate { + from: ClientId, + senior: ClientId, + junior: ClientId, + }, /// These characters' families changed; those online read theirs again. FamilyChanged { ids: Vec,