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