From c11b862c5f7d048541a0fac54165fcf839c0502f Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 02:57:52 -0400 Subject: [PATCH 1/3] Job buffs and foreign buffs (L154, L156) Server: buff skills give their level's stats plus their own (Booster, Combo, charges, Hyper Body, Maple Warrior, Dark Sight, Shadow Partner, ...), with MP, cooldown and item costs. GIVE_BUFF carries every stat in the client's bit order with the exact tail. The stats others see go out as GIVE/CANCEL_FOREIGN_BUFF (0xC7/0xC8) on use, cancel, expiry and death, and in SPAWN_PLAYER. Damage caps count attack buffs; Shadow Partner doubles a shot's lines and Soul Arrow needs no arrows. A player leaving a field is shown before mob/NPC control passes on. HC: BuffParser reads the flags in the v83 decode order (FUN_00781d0e) and the remote encoding (FUN_00788156, two-state blocks). Buffstat::by_bit replaces the unordered maps. New 199/200 handlers and the spawn block keep remote buffs on OtherChar. Harness: strict packet loops skip NPC control (0x103). --- HeavenClient/CMakeLists.txt | 5 + HeavenClient/Character/Buff.cpp | 136 ++++++-------- HeavenClient/Character/Buff.h | 6 +- HeavenClient/Character/OtherChar.cpp | 22 +++ HeavenClient/Character/OtherChar.h | 15 ++ HeavenClient/Gameplay/Spawn.cpp | 5 +- HeavenClient/Gameplay/Spawn.h | 7 +- .../Net/Handlers/Helpers/BuffParser.cpp | 154 ++++++++++++++++ .../Net/Handlers/Helpers/BuffParser.h | 55 ++++++ .../Net/Handlers/MapObjectHandlers.cpp | 40 ++-- HeavenClient/Net/Handlers/MapObjectHandlers.h | 14 ++ HeavenClient/Net/Handlers/PlayerHandlers.cpp | 26 +-- HeavenClient/Net/PacketSwitch.cpp | 4 + HeavenClient/tests/buff_parser.cpp | 100 ++++++++++ game-data/src/skills.rs | 24 +++ game/src/buff.rs | 85 ++++++++- game/src/character.rs | 5 +- integration-harness/src/protocol.rs | 3 +- integration-harness/tests/cash_shop.rs | 1 + integration-harness/tests/change_channel.rs | 2 + integration-harness/tests/map_npc_spawn.rs | 1 + integration-harness/tests/npc_dialogue.rs | 2 + .../tests/portal_map_transfer.rs | 1 + .../tests/presence_portal_transfer.rs | 3 +- net/src/combat.rs | 75 +++++++- net/src/handler.rs | 3 + net/src/packet/build/world/buff.rs | 168 ++++++++++++++--- net/src/packet/build/world/field.rs | 52 ++++-- net/src/packet/handle/world/use_skill.rs | 172 ++++++++++++++---- net/src/packet/op/send.rs | 2 + net/src/skills.rs | 62 ++++++- runtime/src/actor/client.rs | 1 + runtime/src/actor/client/buff.rs | 32 +++- runtime/src/actor/field.rs | 10 +- 34 files changed, 1091 insertions(+), 202 deletions(-) create mode 100644 HeavenClient/Net/Handlers/Helpers/BuffParser.cpp create mode 100644 HeavenClient/Net/Handlers/Helpers/BuffParser.h create mode 100644 HeavenClient/tests/buff_parser.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 5af8875ac..263bb413f 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -139,6 +139,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(movement_path_tests tests/movement_path.cpp Net/InPacket.cpp Net/Handlers/Helpers/MovementParser.cpp) target_include_directories(movement_path_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME movement_path COMMAND movement_path_tests) + add_executable(buff_parser_tests tests/buff_parser.cpp Net/InPacket.cpp Net/Handlers/Helpers/BuffParser.cpp Character/Buff.cpp) + target_include_directories(buff_parser_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME buff_parser COMMAND buff_parser_tests) add_executable(status_messages_tests tests/status_messages.cpp Net/InPacket.cpp) target_include_directories(status_messages_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME status_messages COMMAND status_messages_tests) @@ -567,6 +570,8 @@ add_executable(HeavenClient Net/Handlers/Helpers/LoginParser.h Net/Handlers/Helpers/MovementParser.cpp Net/Handlers/Helpers/MovementParser.h + Net/Handlers/Helpers/BuffParser.cpp + Net/Handlers/Helpers/BuffParser.h Net/Handlers/AttackHandlers.cpp Net/Handlers/AttackHandlers.h Net/Handlers/BuddyHandlers.cpp diff --git a/HeavenClient/Character/Buff.cpp b/HeavenClient/Character/Buff.cpp index f178c9e3e..091a6f853 100644 --- a/HeavenClient/Character/Buff.cpp +++ b/HeavenClient/Character/Buff.cpp @@ -21,80 +21,64 @@ namespace ms { namespace Buffstat { - const std::unordered_map first_codes = - { - {Id::DASH2, 0x8000000000000L}, - {Id::DASH, 0x10000000000000L}, - {Id::ELEMENTAL_RESET, 0x200000000L}, - {Id::ARAN_COMBO, 0x1000000000L}, - {Id::COMBO_DRAIN, 0x2000000000L}, - {Id::COMBO_BARRIER, 0x4000000000L}, - {Id::BODY_PRESSURE, 0x8000000000L}, - {Id::SMART_KNOCKBACK, 0x10000000000L}, - {Id::PYRAMID_PQ, 0x20000000000L}, - {Id::ENERGY_CHARGE, 0x4000000000000L}, - {Id::MONSTER_RIDING, 0x20000000000000L}, - {Id::HOMING_BEACON, 0x80000000000000L}, - {Id::SPEED_INFUSION, 0x100000000000000L} - }; - - const std::unordered_map second_codes = - { - {Id::NONE, 0x0}, - {Id::MORPH, 0x2}, - {Id::RECOVERY, 0x4}, - {Id::MAPLE_WARRIOR, 0x8}, - {Id::STANCE, 0x10}, - {Id::SHARP_EYES, 0x20}, - {Id::MANA_REFLECTION, 0x40}, - {Id::SHADOW_CLAW, 0x100}, - {Id::INFINITY_, 0x200}, - {Id::HOLY_SHIELD, 0x400}, - {Id::HAMSTRING, 0x800}, - {Id::BLIND, 0x1000}, - {Id::CONCENTRATE, 0x2000}, - {Id::ECHO_OF_HERO, 0x8000}, - {Id::GHOST_MORPH, 0x20000}, - {Id::AURA, 0x40000}, - {Id::CONFUSE, 0x80000}, - {Id::BERSERK_FURY, 0x8000000}, - {Id::DIVINE_BODY, 0x10000000}, - {Id::SPARK, 0x20000000L}, - {Id::FINALATTACK, 0x80000000L}, - {Id::BATTLESHIP, 0xA00000040L}, - {Id::WATK, 0x100000000L}, - {Id::WDEF, 0x200000000L}, - {Id::MATK, 0x400000000L}, - {Id::MDEF, 0x800000000L}, - {Id::ACC, 0x1000000000L}, - {Id::AVOID, 0x2000000000L}, - {Id::HANDS, 0x4000000000L}, - {Id::SHOWDASH, 0x4000000000L}, - {Id::SPEED, 0x8000000000L}, - {Id::JUMP, 0x10000000000L}, - {Id::MAGIC_GUARD, 0x20000000000L}, - {Id::DARKSIGHT, 0x40000000000L}, - {Id::BOOSTER, 0x80000000000L}, - {Id::POWERGUARD, 0x100000000000L}, - {Id::HYPERBODYHP, 0x200000000000L}, - {Id::HYPERBODYMP, 0x400000000000L}, - {Id::INVINCIBLE, 0x800000000000L}, - {Id::SOULARROW, 0x1000000000000L}, - {Id::STUN, 0x2000000000000L}, - {Id::POISON, 0x4000000000000L}, - {Id::SEAL, 0x8000000000000L}, - {Id::DARKNESS, 0x10000000000000L}, - {Id::COMBO, 0x20000000000000L}, - {Id::SUMMON, 0x20000000000000L}, - {Id::WK_CHARGE, 0x40000000000000L}, - {Id::DRAGONBLOOD, 0x80000000000000L}, - {Id::HOLY_SYMBOL, 0x100000000000000L}, - {Id::MESOUP, 0x200000000000000L}, - {Id::SHADOWPARTNER, 0x400000000000000L}, - {Id::PICKPOCKET, 0x800000000000000L}, - {Id::PUPPET, 0x800000000000000L}, - {Id::MESOGUARD, 0x1000000000000000L}, - {Id::WEAKEN, 0x4000000000000000L} - }; + // The flag bits as v83 numbers them (1 << bit of a CFlag<128>). Bits + // 82 and up are the two-state stats. + Id by_bit(uint8_t bit) + { + switch (bit) + { + case 0: return WATK; + case 1: return WDEF; + case 2: return MATK; + case 3: return MDEF; + case 4: return ACC; + case 5: return AVOID; + case 6: return HANDS; + case 7: return SPEED; + case 8: return JUMP; + case 9: return MAGIC_GUARD; + case 10: return DARKSIGHT; + case 11: return BOOSTER; + case 12: return POWERGUARD; + case 13: return HYPERBODYHP; + case 14: return HYPERBODYMP; + case 15: return INVINCIBLE; + case 16: return SOULARROW; + case 17: return STUN; + case 18: return POISON; + case 19: return SEAL; + case 20: return DARKNESS; + case 21: return COMBO; + case 22: return WK_CHARGE; + case 23: return DRAGONBLOOD; + case 24: return HOLY_SYMBOL; + case 25: return MESOUP; + case 26: return SHADOWPARTNER; + case 27: return PICKPOCKET; + case 28: return MESOGUARD; + case 30: return WEAKEN; + case 33: return MORPH; + case 34: return RECOVERY; + case 35: return MAPLE_WARRIOR; + case 36: return STANCE; + case 37: return SHARP_EYES; + case 38: return MANA_REFLECTION; + case 40: return SHADOW_CLAW; + case 41: return INFINITY_; + case 42: return HOLY_SHIELD; + case 43: return HAMSTRING; + case 44: return BLIND; + case 45: return CONCENTRATE; + case 47: return ECHO_OF_HERO; + case 49: return GHOST_MORPH; + case 82: return ENERGY_CHARGE; + case 83: return DASH2; + case 84: return DASH; + case 85: return MONSTER_RIDING; + case 87: return HOMING_BEACON; + case 88: return SPEED_INFUSION; + default: return NONE; + } + } } -} \ No newline at end of file +} diff --git a/HeavenClient/Character/Buff.h b/HeavenClient/Character/Buff.h index e5c19fb4c..9d5b6b078 100644 --- a/HeavenClient/Character/Buff.h +++ b/HeavenClient/Character/Buff.h @@ -18,7 +18,6 @@ #pragma once #include -#include namespace ms { @@ -97,8 +96,9 @@ namespace ms LENGTH }; - extern const std::unordered_map first_codes; - extern const std::unordered_map second_codes; + // The stat with flag bit `bit` of the 128 a buff packet carries + // (CFlag<128>), or NONE for one HeavenClient doesn't keep. + Id by_bit(uint8_t bit); } struct Buff diff --git a/HeavenClient/Character/OtherChar.cpp b/HeavenClient/Character/OtherChar.cpp index 09cd54607..627f2d24b 100644 --- a/HeavenClient/Character/OtherChar.cpp +++ b/HeavenClient/Character/OtherChar.cpp @@ -79,6 +79,28 @@ namespace ms set_state(path.get_state().stance); } + void OtherChar::show_buffs(const std::vector> &shown) + { + for (auto [stat, value] : shown) + buffs[stat] = value; + } + + void OtherChar::hide_buffs(const std::vector &ended) + { + for (Buffstat::Id stat : ended) + buffs.erase(stat); + } + + std::optional OtherChar::get_buff(Buffstat::Id stat) const + { + auto iter = buffs.find(stat); + + if (iter == buffs.end()) + return std::nullopt; + + return iter->second; + } + int8_t OtherChar::get_integer_attackspeed() const { return attackspeed; diff --git a/HeavenClient/Character/OtherChar.h b/HeavenClient/Character/OtherChar.h index 5992b0137..824ff0b02 100644 --- a/HeavenClient/Character/OtherChar.h +++ b/HeavenClient/Character/OtherChar.h @@ -21,12 +21,16 @@ #include +#include "Buff.h" #include "Char.h" #include "Look/CharLook.h" #include "../Gameplay/MovePath.h" +#include +#include +#include #include namespace ms @@ -53,6 +57,16 @@ namespace ms // Update the character look. void update_look(const LookEntry &look); + // Show these buffs, each with its value, as well as those shown + // already. + void show_buffs(const std::vector> &shown); + + // Stop showing these buffs. + void hide_buffs(const std::vector &ended); + + // The value of a buff the character shows. + std::optional get_buff(Buffstat::Id stat) const; + // Return the character's attacking speed. int8_t get_integer_attackspeed() const override; @@ -69,5 +83,6 @@ namespace ms std::unordered_map skilllevels; uint8_t attackspeed; + std::map buffs; }; } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Spawn.cpp b/HeavenClient/Gameplay/Spawn.cpp index b4a2c7a50..c53c7fa24 100644 --- a/HeavenClient/Gameplay/Spawn.cpp +++ b/HeavenClient/Gameplay/Spawn.cpp @@ -107,8 +107,8 @@ namespace ms } CharSpawn::CharSpawn(int32_t c, const LookEntry &lk, uint8_t l, int16_t j, const std::string &nm, int8_t st, - Point p, int32_t ch) : cid(c), look(lk), level(l), job(j), name(nm), stance(st), - position(p), chair(ch) + Point p, int32_t ch, std::vector> bf) : cid(c), + look(lk), level(l), job(j), name(nm), stance(st), position(p), chair(ch), buffs(std::move(bf)) {} int32_t CharSpawn::get_cid() const @@ -120,6 +120,7 @@ namespace ms { auto otherchar = std::make_unique(cid, look, level, job, name, stance, position); otherchar->set_chair(chair); + otherchar->show_buffs(buffs); return otherchar; } diff --git a/HeavenClient/Gameplay/Spawn.h b/HeavenClient/Gameplay/Spawn.h index 1e2c82469..41eb6c175 100644 --- a/HeavenClient/Gameplay/Spawn.h +++ b/HeavenClient/Gameplay/Spawn.h @@ -23,10 +23,13 @@ #include "MapleMap/MapObject.h" +#include "../Character/Buff.h" #include "../Graphics/Animation.h" #include "../Net/Login.h" #include +#include +#include namespace ms { @@ -120,7 +123,8 @@ namespace ms { public: CharSpawn(int32_t cid, const LookEntry &look, uint8_t level, int16_t job, const std::string &name, - int8_t stance, Point position, int32_t chair); + int8_t stance, Point position, int32_t chair, + std::vector> buffs); int32_t get_cid() const; @@ -135,5 +139,6 @@ namespace ms Point position; int32_t chair; LookEntry look; + std::vector> buffs; }; } \ No newline at end of file diff --git a/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp b/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp new file mode 100644 index 000000000..f51847ab4 --- /dev/null +++ b/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp @@ -0,0 +1,154 @@ +////////////////////////////////////////////////////////////////////////////////// +// This file is part of the continued Journey MMORPG client // +// Copyright (C) 2015-2019 Daniel Allendorf, Ryan Payton // +// // +// This program is free software: you can redistribute it and/or modify // +// it under the terms of the GNU Affero General Public License as published by // +// the Free Software Foundation, either version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU Affero General Public License for more details. // +// // +// You should have received a copy of the GNU Affero General Public License // +// along with this program. If not, see . // +#include "BuffParser.h" + +namespace ms +{ + namespace + { + // Bits 0..63 are the second long, 64..127 the first; within each, + // the upper dword comes first. + uint64_t mask(uint8_t bit) + { + uint8_t shift = bit % 64; + + return uint64_t(1) << (shift < 32 ? shift + 32 : shift - 32); + } + + // The two-state stats (bits 82..88): each has a value, a reason and + // when it was last updated, some with more (vtable +0x18 of each). + constexpr uint8_t TWO_STATE = 82; + constexpr size_t TWO_STATE_SIZE[7] = { 15, 15, 15, 13, 20, 17, 15 }; + + enum Data + { + NOTHING, + BYTE, + SHORT, + INT, + SHORT_INT, + INT_BYTE_BYTE + }; + + // What another character's client reads for each stat it shows, + // in its order. + constexpr std::pair SHOWN[] = { + { 7, BYTE }, { 21, BYTE }, { 22, INT }, { 17, INT }, { 20, INT }, { 19, INT }, { 30, INT }, + { 31, INT }, { 18, SHORT_INT }, { 26, NOTHING }, { 10, NOTHING }, { 16, NOTHING }, { 33, SHORT }, + { 49, SHORT }, { 39, INT }, { 40, INT }, { 46, INT }, { 50, INT }, { 62, INT }, { 51, INT }, + { 53, INT }, { 54, INT }, { 55, INT }, { 56, INT }, { 59, NOTHING }, { 60, NOTHING }, + { 66, NOTHING }, { 73, INT }, { 75, INT }, { 76, INT }, { 77, INT_BYTE_BYTE } + }; + + // GIVE_BUFF reads in ascending order, except for these. + constexpr uint8_t GIVEN[] = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 49, 34, 35, 36, 37, 38, 39, + 40, 41, 42, 43, 44, 45, 46, 47, 50, 62, 51, 48, 52, 53, 54, 55, 56, 59, 60, 61, + 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 57, 58, 75, 76, 77, 78, 79, 80, 81 + }; + } + + bool BuffParser::Flags::has(uint8_t bit) const + { + return ((bit < 64 ? second : first) & mask(bit)) != 0; + } + + void BuffParser::Flags::set(uint8_t bit) + { + (bit < 64 ? second : first) |= mask(bit); + } + + BuffParser::Flags BuffParser::read_flags(InPacket& recv) + { + uint64_t first = recv.read_long(); + uint64_t second = recv.read_long(); + + return { first, second }; + } + + std::vector BuffParser::given(const Flags& flags) + { + std::vector bits; + + for (uint8_t bit : GIVEN) + if (flags.has(bit)) + bits.push_back(bit); + + return bits; + } + + std::vector BuffParser::stats(const Flags& flags) + { + std::vector ids; + + for (uint8_t bit = 0; bit < 128; bit++) + if (flags.has(bit) && Buffstat::by_bit(bit) != Buffstat::NONE) + ids.push_back(Buffstat::by_bit(bit)); + + return ids; + } + + std::vector> BuffParser::parse_shown(InPacket& recv, const Flags& flags) + { + std::vector> shown; + + for (auto [bit, data] : SHOWN) + { + if (!flags.has(bit)) + continue; + + int32_t value = 0; + + switch (data) + { + case NOTHING: + break; + case BYTE: + value = static_cast(recv.read_byte()); + break; + case SHORT: + value = recv.read_short(); + break; + case INT: + value = recv.read_int(); + break; + case SHORT_INT: + value = recv.read_short(); + recv.skip_int(); + break; + case INT_BYTE_BYTE: + value = recv.read_int(); + recv.skip(2); + break; + } + + Buffstat::Id stat = Buffstat::by_bit(bit); + + if (stat != Buffstat::NONE) + shown.emplace_back(stat, value); + } + + recv.skip(2); // defense attack and state + + for (uint8_t slot = 0; slot < 7; slot++) + if (flags.has(TWO_STATE + slot)) + recv.skip(TWO_STATE_SIZE[slot]); + + return shown; + } +} diff --git a/HeavenClient/Net/Handlers/Helpers/BuffParser.h b/HeavenClient/Net/Handlers/Helpers/BuffParser.h new file mode 100644 index 000000000..4d5c92c3d --- /dev/null +++ b/HeavenClient/Net/Handlers/Helpers/BuffParser.h @@ -0,0 +1,55 @@ +////////////////////////////////////////////////////////////////////////////////// +// This file is part of the continued Journey MMORPG client // +// Copyright (C) 2015-2019 Daniel Allendorf, Ryan Payton // +// // +// This program is free software: you can redistribute it and/or modify // +// it under the terms of the GNU Affero General Public License as published by // +// the Free Software Foundation, either version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU Affero General Public License for more details. // +// // +// You should have received a copy of the GNU Affero General Public License // +// along with this program. If not, see . // +#pragma once + +#include "../Net/InPacket.h" +#include "../Character/Buff.h" + +#include +#include +#include + +namespace ms +{ + // The temporary stats of buff packets, as v83 reads them. + namespace BuffParser + { + // The 128 stat flags: bit n of the 16 bytes as they come. + struct Flags + { + uint64_t first; + uint64_t second; + + bool has(uint8_t bit) const; + void set(uint8_t bit); + }; + + Flags read_flags(InPacket& recv); + + // The set flags below the two-state stats, in the order GIVE_BUFF + // carries their data (FUN_00781d0e). + std::vector given(const Flags& flags); + + // The stats HeavenClient keeps among those set. + std::vector stats(const Flags& flags); + + // The stats another character shows, and each one's value (FUN_00788156), + // as SPAWN_PLAYER and GIVE_FOREIGN_BUFF carry them: their data, the + // defense attack and state, then the two-state stats' data. + std::vector> parse_shown(InPacket& recv, const Flags& flags); + } +} diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index c9f950bc6..f04c494a9 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #include "MapObjectHandlers.h" +#include "Helpers/BuffParser.h" #include "Helpers/LoginParser.h" #include "Helpers/MovementParser.h" @@ -41,22 +42,8 @@ namespace ms recv.read_short(); // guildlogo recv.read_byte(); // guildlogocolor - recv.skip(8); - - bool morphed = recv.read_int() == 2; - int32_t buffmask1 = recv.read_int(); - int16_t buffvalue = 0; - - if (buffmask1 != 0) - buffvalue = morphed ? recv.read_short() : recv.read_byte(); - - recv.read_int(); // buffmask 2 - - recv.skip(43); - - recv.read_int(); // 'mount' - - recv.skip(61); + BuffParser::Flags flags = BuffParser::read_flags(recv); + auto buffs = BuffParser::parse_shown(recv, flags); int16_t job = recv.read_short(); LookEntry look = LoginParser::parse_look(recv); @@ -106,10 +93,29 @@ namespace ms recv.read_byte(); // team Stage::get().get_chars().spawn( - { cid, look, level, job, name, stance, position, chair } + { cid, look, level, job, name, stance, position, chair, std::move(buffs) } ); } + void GiveForeignBuffHandler::handle(InPacket& recv) const + { + int32_t cid = recv.read_int(); + BuffParser::Flags flags = BuffParser::read_flags(recv); + auto buffs = BuffParser::parse_shown(recv, flags); + + if (Optional otherchar = Stage::get().get_chars().get_char(cid)) + otherchar->show_buffs(buffs); + } + + void CancelForeignBuffHandler::handle(InPacket& recv) const + { + int32_t cid = recv.read_int(); + BuffParser::Flags flags = BuffParser::read_flags(recv); + + if (Optional otherchar = Stage::get().get_chars().get_char(cid)) + otherchar->hide_buffs(BuffParser::stats(flags)); + } + void RemoveCharHandler::handle(InPacket& recv) const { int32_t cid = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index baae31e06..96694f662 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -85,6 +85,20 @@ namespace ms void handle(InPacket& recv) const override; }; + // Another character started showing buffs + // Opcode: GIVE_FOREIGN_BUFF(199) + class GiveForeignBuffHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Another character stopped showing buffs + // Opcode: CANCEL_FOREIGN_BUFF(200) + class CancelForeignBuffHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Spawn a mob on the stage // Opcode: SPAWN_MOB(236) class SpawnMobHandler : public PacketHandler diff --git a/HeavenClient/Net/Handlers/PlayerHandlers.cpp b/HeavenClient/Net/Handlers/PlayerHandlers.cpp index 355942fee..4f8ea316b 100644 --- a/HeavenClient/Net/Handlers/PlayerHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerHandlers.cpp @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #include "PlayerHandlers.h" +#include "Helpers/BuffParser.h" #include "Helpers/LoginParser.h" #include "../Packets/LoginPackets.h" @@ -192,23 +193,10 @@ namespace ms void BuffHandler::handle(InPacket& recv) const { - uint64_t firstmask = recv.read_long(); - uint64_t secondmask = recv.read_long(); + BuffParser::Flags flags = BuffParser::read_flags(recv); - switch (secondmask) - { - case Buffstat::BATTLESHIP: - handle_buff(recv, Buffstat::BATTLESHIP); - return; - } - - for (auto& iter : Buffstat::first_codes) - if (firstmask & iter.second) - handle_buff(recv, iter.first); - - for (auto& iter : Buffstat::second_codes) - if (secondmask & iter.second) - handle_buff(recv, iter.first); + for (uint8_t bit : BuffParser::given(flags)) + handle_buff(recv, Buffstat::by_bit(bit)); Stage::get().get_player().recalc_stats(false); } @@ -219,6 +207,9 @@ namespace ms int32_t skillid = recv.read_int(); int32_t duration = recv.read_int(); + if (bs == Buffstat::NONE) + return; + Stage::get().get_player().give_buff({ bs, value, skillid, duration }); if (auto bufflist = UI::get().get_element()) @@ -227,6 +218,9 @@ namespace ms void CancelBuffHandler::handle_buff(InPacket&, Buffstat::Id bs) const { + if (bs == Buffstat::NONE) + return; + Stage::get().get_player().cancel_buff(bs); if (auto bufflist = UI::get().get_element()) diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index 90e40ea80..b5565328c 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -118,6 +118,8 @@ namespace ms CANCEL_CHAIR = 205, UPDATE_CHARLOOK = 197, SHOW_FOREIGN_EFFECT = 198, + GIVE_FOREIGN_BUFF = 199, + CANCEL_FOREIGN_BUFF = 200, SHOW_ITEM_GAIN_INCHAT = 206, // TODO: Rename this (Terribly named) /// Player @@ -190,6 +192,8 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/tests/buff_parser.cpp b/HeavenClient/tests/buff_parser.cpp new file mode 100644 index 000000000..f36c4f10b --- /dev/null +++ b/HeavenClient/tests/buff_parser.cpp @@ -0,0 +1,100 @@ +#include "../Net/Handlers/Helpers/BuffParser.h" +#include +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +struct Writer +{ + std::vector bytes; + + template + Writer& put(T value) + { + int8_t raw[sizeof(T)]; + std::memcpy(raw, &value, sizeof(T)); + bytes.insert(bytes.end(), raw, raw + sizeof(T)); + return *this; + } + + Writer& flags(const ms::BuffParser::Flags& flags) + { + return put(flags.first).put(flags.second); + } + + Writer& zeros(size_t count) + { + bytes.insert(bytes.end(), count, 0); + return *this; + } +}; + +int main() +{ + try + { + using namespace ms; + + BuffParser::Flags speed{ 0, 0 }; + speed.set(7); + require(speed.second == 0x8000000000ULL && speed.first == 0, "Speed is bit 7, in the second long"); + + BuffParser::Flags flags{ 0, 0 }; + flags.set(34); + flags.set(82); + require(flags.second == 0x4 && flags.first == 0x4000000000000ULL, + "Recovery is bit 34 and energy charge bit 82, in the first long"); + + BuffParser::Flags given{ 0, 0 }; + for (uint8_t bit : { 14, 13, 34, 49, 58, 74, 82 }) + given.set(bit); + require(BuffParser::given(given) == std::vector({ 13, 14, 49, 34, 74, 58 }), + "GIVE_BUFF reads in ascending order with v83's exceptions, and not the two-state stats"); + + BuffParser::Flags hyperbody{ 0, 0 }; + hyperbody.set(13); + hyperbody.set(14); + require(BuffParser::stats(hyperbody) == std::vector({ Buffstat::HYPERBODYHP, Buffstat::HYPERBODYMP }), + "each set bit names its stat"); + require(Buffstat::by_bit(29) == Buffstat::NONE, "stats HeavenClient doesn't keep are NONE"); + + // SPAWN_PLAYER: Speed and Dark Sight, then all seven two-state stats. + BuffParser::Flags spawn{ 0, 0 }; + for (uint8_t bit : { 7, 10, 82, 83, 84, 85, 86, 87, 88 }) + spawn.set(bit); + Writer w; + w.flags(spawn).put(140).zeros(2).zeros(110).put(412); + InPacket recv(w.bytes.data(), w.bytes.size()); + auto shown = BuffParser::parse_shown(recv, BuffParser::read_flags(recv)); + require(shown.size() == 2 && shown[0].first == Buffstat::SPEED && shown[0].second == 140 && + shown[1].first == Buffstat::DARKSIGHT, "a spawned character shows its buffs"); + require(recv.read_short() == 412 && !recv.available(), "the two-state stats take 110 bytes"); + + // GIVE_FOREIGN_BUFF: the data comes in the client's fixed order. + BuffParser::Flags remote{ 0, 0 }; + for (uint8_t bit : { 18, 21, 22, 26 }) + remote.set(bit); + Writer c; + c.flags(remote).put(3).put(1211004).put(5).put(2111003).zeros(2).put(0); + InPacket buff(c.bytes.data(), c.bytes.size()); + shown = BuffParser::parse_shown(buff, BuffParser::read_flags(buff)); + require(shown.size() == 4 && shown[0].first == Buffstat::COMBO && shown[0].second == 3 && + shown[1].first == Buffstat::WK_CHARGE && shown[1].second == 1211004 && + shown[2].first == Buffstat::POISON && shown[2].second == 5 && + shown[3].first == Buffstat::SHADOWPARTNER, "combo, charge, poison and shadow partner"); + require(buff.read_short() == 0 && !buff.available(), "the delay follows"); + + std::cout << "Buff parser passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/game-data/src/skills.rs b/game-data/src/skills.rs index 222e6e338..d10de1e9d 100644 --- a/game-data/src/skills.rs +++ b/game-data/src/skills.rs @@ -57,6 +57,21 @@ pub struct SkillLevel { /// Endure do (`hp`, `mp`). pub hp: i32, pub mp: i32, + /// What a buff adds to attack, defense, accuracy, avoidability, and jump + /// (`pad`, `pdd`, `mdd`, `acc`, `eva`, `jump`); magic attack is `magic` + /// and speed `speed`. + pub pad: i32, + pub pdd: i32, + pub mdd: i32, + pub acc: i32, + pub eva: i32, + pub jump: i32, + /// A chance in percent, e.g. Power Stance's (`prop`). + pub prop: i32, + /// An item each use costs, and how many (`itemCon`, `itemConNo`), as + /// Shadow Partner's Summoning Rock. + pub item_cost: i32, + pub item_cost_count: i32, } pub struct SkillData { @@ -109,6 +124,15 @@ impl SkillData { y: int("y")?, hp: int("hp")?, mp: int("mp")?, + pad: int("pad")?, + pdd: int("pdd")?, + mdd: int("mdd")?, + acc: int("acc")?, + eva: int("eva")?, + jump: int("jump")?, + prop: int("prop")?, + item_cost: int("itemCon")?, + item_cost_count: int("itemConNo")?, }); } } diff --git a/game/src/buff.rs b/game/src/buff.rs index 38e1aec2a..6aed0ce5e 100644 --- a/game/src/buff.rs +++ b/game/src/buff.rs @@ -6,11 +6,66 @@ use std::time::{Duration, Instant}; /// How often Recovery heals. pub const RECOVERY_INTERVAL: Duration = Duration::from_secs(5); -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// A temporary stat, numbered by its bit in the v83 client's 128-bit +/// temporary-stat flags (built as `1 << n` at startup, e.g. 0x78d9ad). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] pub enum BuffStat { + /// Weapon attack, weapon defense, magic attack, magic defense, + /// accuracy, and avoidability. + Pad = 0, + Pdd = 1, + Mad = 2, + Mdd = 3, + Acc = 4, + Eva = 5, + Speed = 7, + Jump = 8, + MagicGuard = 9, + DarkSight = 10, + Booster = 11, + PowerGuard = 12, + MaxHp = 13, + MaxMp = 14, + Invincible = 15, + SoulArrow = 16, + Combo = 21, + WeaponCharge = 22, + DragonBlood = 23, + HolySymbol = 24, + MesoUp = 25, + ShadowPartner = 26, + PickPocket = 27, + MesoGuard = 28, /// Heals `value` HP every `RECOVERY_INTERVAL`. - Recovery, - Speed, + Recovery = 34, + MapleWarrior = 35, + Stance = 36, + SharpEyes = 37, + Infinity = 41, + Concentrate = 45, +} + +impl BuffStat { + /// Its bit in the client's flags. + pub fn bit(self) -> u8 { + self as u8 + } + + /// Stats the rest of the map is told about, in the order the client + /// reads them (FUN_00788156); the others only matter to the player. + pub const SHOWN: [BuffStat; 6] = [ + BuffStat::Speed, + BuffStat::Combo, + BuffStat::WeaponCharge, + BuffStat::ShadowPartner, + BuffStat::DarkSight, + BuffStat::SoulArrow, + ]; + + pub fn shown(self) -> bool { + Self::SHOWN.contains(&self) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,6 +129,30 @@ impl Buffs { &self.active } + /// The running buffs the rest of the map sees, with what they're told: + /// the value, or for a weapon charge the skill (its element). + pub fn shown(&self) -> Vec<(BuffStat, i32)> { + BuffStat::SHOWN + .iter() + .filter_map(|&stat| { + let buff = self.active.iter().find(|buff| buff.stat == stat)?; + let value = match stat { + BuffStat::WeaponCharge => buff.skill_id, + _ => i32::from(buff.value), + }; + Some((stat, value)) + }) + .collect() + } + + /// The value of a running buff on this stat. + pub fn value(&self, stat: BuffStat) -> Option { + self.active + .iter() + .find(|buff| buff.stat == stat) + .map(|buff| buff.value) + } + pub fn cooling_down(&self, skill_id: i32, now: Instant) -> bool { self.cooldowns .iter() diff --git a/game/src/character.rs b/game/src/character.rs index 789fd6da9..4e08f5b00 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -1,4 +1,4 @@ -use crate::buff::Buffs; +use crate::buff::{BuffStat, Buffs}; use crate::inventory::{equip_slot, Inventory}; use crate::keymap::Keymap; use crate::monster_book::MonsterBook; @@ -122,6 +122,8 @@ pub struct FieldAvatar { pub stance: u8, /// The portable chair (a 301xxxx setup item) the player sits on, or 0. pub chair: i32, + /// The buffs others see, as `Buffs::shown`. + pub buffs: Vec<(BuffStat, i32)>, } /// A character that is logged in. The owning client actor holds the only @@ -200,6 +202,7 @@ impl Player { y, stance, chair: 0, + buffs: self.buffs.shown(), } } diff --git a/integration-harness/src/protocol.rs b/integration-harness/src/protocol.rs index 56c4768c7..962ba9374 100644 --- a/integration-harness/src/protocol.rs +++ b/integration-harness/src/protocol.rs @@ -369,10 +369,11 @@ async fn drain_spawn_npcs(connection: &mut MapleTestConnection) { ) .await { - // Map setup: NPC spawns and the map entry banner. + // Map setup: NPC spawns and control, and the map entry banner. // ...and the map's mobs, drops, and reactors. if ![ SendOpcode::SpawnNpc as i16, + SendOpcode::SpawnNpcRequestController as i16, SendOpcode::FieldEffect as i16, SendOpcode::SpawnMonster as i16, SendOpcode::SpawnMonsterControl as i16, diff --git a/integration-harness/tests/cash_shop.rs b/integration-harness/tests/cash_shop.rs index e9a615fa4..271dd462d 100644 --- a/integration-harness/tests/cash_shop.rs +++ b/integration-harness/tests/cash_shop.rs @@ -28,6 +28,7 @@ async fn read_until( } let skippable = [ SendOpcode::SpawnNpc as i16, + SendOpcode::SpawnNpcRequestController as i16, SendOpcode::FieldEffect as i16, SendOpcode::SpawnPlayer as i16, SendOpcode::RemovePlayerFromMap as i16, diff --git a/integration-harness/tests/change_channel.rs b/integration-harness/tests/change_channel.rs index f555eb8cd..4d274bc8a 100644 --- a/integration-harness/tests/change_channel.rs +++ b/integration-harness/tests/change_channel.rs @@ -30,6 +30,7 @@ async fn read_world_entry( x if x == SendOpcode::KeyMap as i16 => saw_keymap = true, x if x == SendOpcode::SetField as i16 => saw_set_field = true, x if x == SendOpcode::SpawnNpc as i16 => {} + x if x == SendOpcode::SpawnNpcRequestController as i16 => {} x if x == SendOpcode::FieldEffect as i16 => {} opcode => { return Err(HarnessError::protocol( @@ -66,6 +67,7 @@ async fn read_until_opcode( match envelope.opcode() { opcode if opcode == expected_opcode => return Ok(envelope), x if x == SendOpcode::SpawnNpc as i16 => {} + x if x == SendOpcode::SpawnNpcRequestController as i16 => {} x if x == SendOpcode::FieldEffect as i16 => {} x if x == SendOpcode::SpawnPlayer as i16 => {} x if x == SendOpcode::RemovePlayerFromMap as i16 => {} diff --git a/integration-harness/tests/map_npc_spawn.rs b/integration-harness/tests/map_npc_spawn.rs index f6b3846ba..92d0a35b2 100644 --- a/integration-harness/tests/map_npc_spawn.rs +++ b/integration-harness/tests/map_npc_spawn.rs @@ -110,6 +110,7 @@ async fn read_next_set_field_warp( } x if x == SendOpcode::StatChange as i16 => continue, x if x == SendOpcode::SpawnNpc as i16 => continue, + x if x == SendOpcode::SpawnNpcRequestController as i16 => continue, x if x == SendOpcode::FieldEffect as i16 => continue, x if x == SendOpcode::SpawnMonster as i16 || x == SendOpcode::SpawnMonsterControl as i16 diff --git a/integration-harness/tests/npc_dialogue.rs b/integration-harness/tests/npc_dialogue.rs index 14e89947a..94e5b186c 100644 --- a/integration-harness/tests/npc_dialogue.rs +++ b/integration-harness/tests/npc_dialogue.rs @@ -85,6 +85,8 @@ async fn page(connection: &mut MapleTestConnection) -> DialoguePage { continue } op if op == SendOpcode::ChatText as i16 => continue, + // NPC control passes on as other tests' players leave. + op if op == SendOpcode::SpawnNpcRequestController as i16 => continue, op => assert_eq!(op, SendOpcode::NpcDialogue as i16), } let mut r = Cursor::new(&packet.bytes); diff --git a/integration-harness/tests/portal_map_transfer.rs b/integration-harness/tests/portal_map_transfer.rs index 8493c682a..20608f2d6 100644 --- a/integration-harness/tests/portal_map_transfer.rs +++ b/integration-harness/tests/portal_map_transfer.rs @@ -151,6 +151,7 @@ async fn read_next_set_field_warp( } x if x == SendOpcode::StatChange as i16 => continue, x if x == SendOpcode::SpawnNpc as i16 => continue, + x if x == SendOpcode::SpawnNpcRequestController as i16 => continue, x if x == SendOpcode::FieldEffect as i16 => continue, x if x == SendOpcode::SpawnMonster as i16 || x == SendOpcode::SpawnMonsterControl as i16 diff --git a/integration-harness/tests/presence_portal_transfer.rs b/integration-harness/tests/presence_portal_transfer.rs index 5a27696c6..7b3216f79 100644 --- a/integration-harness/tests/presence_portal_transfer.rs +++ b/integration-harness/tests/presence_portal_transfer.rs @@ -160,7 +160,7 @@ async fn read_next_set_field_warp( connection: &mut MapleTestConnection, phase: &'static str, ) -> SetFieldWarpPacket { - for _ in 0..32 { + for _ in 0..128 { let envelope = timeout(Duration::from_secs(5), connection.read_packet(phase)) .await .expect("timed out waiting for map-transfer packet") @@ -173,6 +173,7 @@ async fn read_next_set_field_warp( } x if x == SendOpcode::StatChange as i16 => continue, x if x == SendOpcode::SpawnNpc as i16 => continue, + x if x == SendOpcode::SpawnNpcRequestController as i16 => continue, x if x == SendOpcode::FieldEffect as i16 => continue, x if x == SendOpcode::SpawnMonster as i16 || x == SendOpcode::SpawnMonsterControl as i16 diff --git a/net/src/combat.rs b/net/src/combat.rs index 4fa07aa96..fe0a442e2 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -3,7 +3,7 @@ use crate::skills::{self, Learned}; use crate::{error::NetworkError, packet::op::SendOpcode}; use game::inventory::equip_slot; -use game::{InventoryType, Player}; +use game::{BuffStat, InventoryType, Player}; use packet::{io::read::PktRead, io::write::PktWrite, Packet}; use std::io::Cursor; @@ -161,6 +161,11 @@ fn weapon_formula(weapon_id: i32) -> (f64, bool) { } } +/// What a running buff adds to a stat, or 0. +fn buff(player: &Player, stat: BuffStat) -> i32 { + player.buffs.value(stat).map_or(0, i32::from) +} + /// The most one basic hit can do (`calculateMaxBaseDamage`). pub fn max_base_damage(player: &Player) -> i32 { let stats = &player.stats; @@ -170,7 +175,8 @@ pub fn max_base_damage(player: &Player) -> i32 { .items(InventoryType::Equipped) .filter_map(|(_, item)| item.equip.as_ref()) .map(|equip| i32::from(equip.watk)) - .sum(); + .sum::() + + buff(player, BuffStat::Pad); let weapon = worn(equip_slot::WEAPON).map_or(0, |item| item.item_id); let (multiplier, luck_based) = weapon_formula(weapon); let (main, secondary) = match weapon / 10_000 { @@ -194,7 +200,7 @@ pub fn max_magic_damage(player: &Player, spell: i32) -> i32 { .fold((i32::from(player.stats.int), 0), |(int, matk), equip| { (int + i32::from(equip.int), matk + i32::from(equip.matk)) }); - let magic = f64::from(int + matk); + let magic = f64::from(int + matk + buff(player, BuffStat::Mad)); let max = ((magic * magic / 1000.0 + magic) / 30.0 + f64::from(int) / 200.0) * f64::from(spell); max.ceil() as i32 } @@ -246,6 +252,10 @@ fn shot_ammo( .get(InventoryType::Equipped, equip_slot::WEAPON) .ok_or(AttackRejection::Ammo)? .item_id; + // Soul Arrow shoots a bow or crossbow without arrows. + if matches!(weapon / 10_000, 145 | 146) && player.buffs.value(BuffStat::SoulArrow).is_some() { + return Ok(None); + } let quantity = skill.map_or(1, |skill| { let effect = &skill.effect; if effect.bullet_consume != 0 { @@ -335,6 +345,16 @@ pub fn validate(attack: &Attack, player: &Player) -> Result mob_count || i32::from(attack.hit_count) > hit_count || attack.hits.len() > mob_count as usize @@ -729,6 +749,55 @@ mod tests { ); } + #[test] + fn shadow_partner_doubles_a_shot_and_soul_arrow_needs_no_arrows() { + let now = std::time::Instant::now(); + let long = std::time::Duration::from_secs(60); + let shot = |damage: &[i32], slot| { + parse_attack( + &kind_attack_packet(AttackKind::Ranged, 0, slot, damage), + AttackKind::Ranged, + ) + .unwrap() + }; + let mut player = rogue_with_stars(100); + assert_eq!( + validate(&shot(&[1, 1], 1), &player).unwrap_err(), + AttackRejection::TooManyTargets + ); + let partner = game::Buff::new(4111002, BuffStat::ShadowPartner, 80, now, long); + player.buffs.give(partner); + assert!(validate(&shot(&[1, 1], 1), &player).is_ok()); + + player.inventory.insert( + InventoryType::Equipped, + equip_slot::WEAPON, + Item::equipment(1452002, EquipStats::default()), + ); + assert_eq!( + validate(&shot(&[1], 0), &player).unwrap_err(), + AttackRejection::Ammo + ); + let soul_arrow = game::Buff::new(3101004, BuffStat::SoulArrow, 1, now, long); + player.buffs.give(soul_arrow); + assert_eq!(validate(&shot(&[1], 0), &player).unwrap().ammo, None); + } + + #[test] + fn attack_buffs_raise_the_damage_cap() { + let player = beginner_with_sword(); + let mut buffed = player.clone(); + let rage = game::Buff::new( + 1101006, + BuffStat::Pad, + 12, + std::time::Instant::now(), + std::time::Duration::from_secs(60), + ); + buffed.buffs.give(rage); + assert!(max_base_damage(&buffed) > max_base_damage(&player)); + } + #[test] fn arrow_bomb_hits_for_its_x_percent() { if skills::data().is_err() { diff --git a/net/src/handler.rs b/net/src/handler.rs index a1b0f7baf..cc37781bd 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -289,6 +289,9 @@ pub enum WorldAction { /// The player's worn equipment changed; refresh their field avatar and /// show the others this UPDATE_CHAR_LOOK. LookChanged(Packet), + /// The buffs others see changed; refresh the player's field avatar and + /// show the others this GIVE_FOREIGN_BUFF or CANCEL_FOREIGN_BUFF. + BuffsChanged(Packet), /// Natural HP/MP recovery the client reports; the runtime checks timing. NaturalHeal { hp: i16, diff --git a/net/src/packet/build/world/buff.rs b/net/src/packet/build/world/buff.rs index d5d841019..e347ce6aa 100644 --- a/net/src/packet/build/world/buff.rs +++ b/net/src/packet/build/world/buff.rs @@ -4,17 +4,25 @@ use crate::{error::NetworkError, packet::op::SendOpcode}; use game::BuffStat; use packet::{io::write::PktWrite, Packet}; -/// A buff's bit in the client's two 64-bit masks: (first, second). -fn mask(stat: BuffStat) -> (u64, u64) { - match stat { - BuffStat::Recovery => (0, 0x4), - BuffStat::Speed => (0, 0x80_0000_0000), +/// A temporary stat's bit in the client's 128-bit flags, as the two longs +/// the packet carries (first, second). The flags go out as they lie in +/// memory, with bits 0..31 in the last dword and 96..127 in the first. +fn mask(bit: u8) -> (u64, u64) { + match bit { + 0..=31 => (0, 1 << (32 + bit)), + 32..=63 => (0, 1 << (bit - 32)), + 64..=95 => (1 << (bit - 32), 0), + _ => (1 << (bit - 96), 0), } } -fn write_mask(p: &mut Packet, stats: &[BuffStat]) -> Result<(), NetworkError> { - let (first, second) = stats.iter().fold((0, 0), |(first, second), &stat| { - let (f, s) = mask(stat); +/// The two-state stats (energy charge, dash, riding, ...), bits 82..88. +/// A player always carries them; their data has a fixed size. +pub(crate) const TWO_STATE: [u8; 7] = [82, 83, 84, 85, 86, 87, 88]; + +fn write_mask(p: &mut Packet, bits: impl IntoIterator) -> Result<(), NetworkError> { + let (first, second) = bits.into_iter().fold((0, 0), |(first, second), bit| { + let (f, s) = mask(bit); (first | f, second | s) }); p.write_long(first as i64)?; @@ -22,22 +30,39 @@ fn write_mask(p: &mut Packet, stats: &[BuffStat]) -> Result<(), NetworkError> { Ok(()) } -/// GIVE_BUFF: one buff from a skill, lasting `duration_ms`. +/// Stats the client reads one more byte for at the end of GIVE_BUFF +/// (FUN_0077dc78): the ones that change how the player moves. +fn moves(stat: BuffStat) -> bool { + matches!( + stat, + BuffStat::Speed | BuffStat::Jump | BuffStat::MapleWarrior + ) +} + +/// GIVE_BUFF: the stats a skill gives, lasting `duration_ms` +/// (FUN_00a202be). Each stat's value, skill, and duration come in the +/// order of their bits. pub fn build_give_buff( skill_id: i32, duration_ms: i32, - stat: BuffStat, - value: i16, + stats: &[(BuffStat, i16)], ) -> Result { + let mut stats = stats.to_vec(); + stats.sort_by_key(|&(stat, _)| stat.bit()); let mut p = Packet::new_empty(); p.write_short(SendOpcode::GiveBuff as i16)?; - write_mask(&mut p, &[stat])?; - p.write_short(value)?; - p.write_int(skill_id)?; - p.write_int(duration_ms)?; - p.write_int(0)?; - p.write_byte(0)?; - p.write_int(i32::from(value))?; + write_mask(&mut p, stats.iter().map(|&(stat, _)| stat.bit()))?; + for &(_, value) in &stats { + p.write_short(value)?; + p.write_int(skill_id)?; + p.write_int(duration_ms)?; + } + p.write_byte(0)?; // defense attack + p.write_byte(0)?; // defense state + p.write_short(0)?; // delay + if stats.iter().any(|&(stat, _)| moves(stat)) { + p.write_byte(0)?; + } Ok(p) } @@ -45,11 +70,67 @@ pub fn build_give_buff( pub fn build_cancel_buff(stats: &[BuffStat]) -> Result { let mut p = Packet::new_empty(); p.write_short(SendOpcode::CancelBuff as i16)?; - write_mask(&mut p, stats)?; + write_mask(&mut p, stats.iter().map(|stat| stat.bit()))?; p.write_byte(1)?; Ok(p) } +/// The buffs another player shows (FUN_00788156), as SPAWN_PLAYER and +/// GIVE_FOREIGN_BUFF carry them: the flags, then each one's data in the +/// client's fixed order, then the defense attack and state. SPAWN_PLAYER +/// also sets the two-state stats, whose data the caller writes next. +pub(crate) fn write_shown( + p: &mut Packet, + shown: &[(BuffStat, i32)], + two_state: bool, +) -> Result<(), NetworkError> { + let two_state = two_state.then_some(TWO_STATE).into_iter().flatten(); + write_mask(p, shown.iter().map(|(stat, _)| stat.bit()).chain(two_state))?; + for stat in BuffStat::SHOWN { + let Some(&(_, value)) = shown.iter().find(|(s, _)| *s == stat) else { + continue; + }; + match stat { + BuffStat::Speed | BuffStat::Combo => { + p.write_byte(value as u8)?; + } + BuffStat::WeaponCharge => { + p.write_int(value)?; + } + _ => {} + } + } + p.write_byte(0)?; // defense attack + p.write_byte(0)?; // defense state + Ok(()) +} + +/// GIVE_FOREIGN_BUFF: another player started showing these buffs +/// (0x98385d); the client leaves the others as they were. +pub fn build_give_foreign_buff( + character_id: i32, + shown: &[(BuffStat, i32)], +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::GiveForeignBuff as i16)?; + p.write_int(character_id)?; + write_shown(&mut p, shown, false)?; + p.write_short(0)?; // delay + Ok(p) +} + +/// CANCEL_FOREIGN_BUFF: another player's shown buffs ended (0x983921). +pub fn build_cancel_foreign_buff( + character_id: i32, + stats: &[BuffStat], +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::CancelForeignBuff as i16)?; + p.write_int(character_id)?; + write_mask(&mut p, stats.iter().map(|stat| stat.bit()))?; + Ok(p) +} + /// COOLDOWN: the skill can't be used for `seconds`. pub fn build_cooldown(skill_id: i32, seconds: i16) -> Result { let mut p = Packet::new_empty(); @@ -97,12 +178,57 @@ mod tests { #[test] fn give_buff_matches_the_client_masks() { - let packet = build_give_buff(1002, 4000, BuffStat::Speed, 10).unwrap(); + let packet = build_give_buff(1002, 4000, &[(BuffStat::Speed, 10)]).unwrap(); let mut expect = Expect::new(&packet.bytes); expect.short(SendOpcode::GiveBuff as i16); expect.long(0).long(0x80_0000_0000); expect.short(10).int(1002).int(4000); - expect.int(0).byte(0).int(10); + // Defense, delay, and the byte for a stat that changes movement. + expect.byte(0).byte(0).short(0).byte(0); + expect.end(); + } + + #[test] + fn give_buff_writes_stats_in_bit_order() { + // Hyper Body, given out of order; neither changes movement. + let stats = [(BuffStat::MaxMp, 60), (BuffStat::MaxHp, 50)]; + let packet = build_give_buff(1301007, 300_000, &stats).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::GiveBuff as i16); + expect.long(0).long(0x6000_0000_0000); + expect.short(50).int(1301007).int(300_000); + expect.short(60).int(1301007).int(300_000); + expect.byte(0).byte(0).short(0); + expect.end(); + } + + #[test] + fn masks_cover_all_four_dwords() { + assert_eq!(mask(0), (0, 1 << 32)); + assert_eq!(mask(34), (0, 4)); + assert_eq!(mask(82), (1 << 50, 0)); + assert_eq!(mask(100), (1 << 4, 0)); + } + + #[test] + fn foreign_buffs_carry_shown_data_in_the_client_order() { + let shown = [ + (BuffStat::SoulArrow, 1), + (BuffStat::WeaponCharge, 1211004), + (BuffStat::Speed, 40), + ]; + let packet = build_give_foreign_buff(7, &shown).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::GiveForeignBuff as i16).int(7); + expect.long(0).long((1 << 39) | (1 << 54) | (1 << 48)); + expect.byte(40).int(1211004); + expect.byte(0).byte(0).short(0); + expect.end(); + + let packet = build_cancel_foreign_buff(7, &[BuffStat::DarkSight]).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::CancelForeignBuff as i16).int(7); + expect.long(0).long(1 << 42); expect.end(); } diff --git a/net/src/packet/build/world/field.rs b/net/src/packet/build/world/field.rs index dd60eca87..ed159994d 100644 --- a/net/src/packet/build/world/field.rs +++ b/net/src/packet/build/world/field.rs @@ -1,3 +1,4 @@ +use super::buff; use crate::codec::encode_avatar_look; use crate::{error::NetworkError, packet::op::SendOpcode}; use game::FieldAvatar; @@ -18,7 +19,7 @@ pub fn build_player_enter_field(avatar: &FieldAvatar) -> Result Result Result<(), NetworkError> { +/// The buffs others see, then the two-state stats' fixed blocks, which +/// are always empty here; the repeated spawn constant mirrors what the +/// reference server sends. +fn write_foreign_buffs( + packet: &mut Packet, + shown: &[(game::BuffStat, i32)], +) -> Result<(), NetworkError> { const CHAR_MAGIC_SPAWN: i32 = 0x2D0F_5E1A; - packet.write_int(0)?; - packet.write_short(0)?; - packet.write_byte(0xFC)?; - packet.write_byte(1)?; - packet.write_int(0)?; // morph - packet.write_int(0)?; // buff mask (high) - packet.write_int(0)?; // buff mask (low) + buff::write_shown(packet, shown, true)?; - packet.write_int(0)?; // energy charge + packet.write_short(0)?; // energy charge + packet.write_int(0)?; packet.write_short(0)?; packet.write_bytes(&[0; 4])?; packet.write_int(0)?; // dash speed @@ -228,7 +227,7 @@ pub fn parse_movement( mod tests { use super::*; use crate::codec::testing::Expect; - use game::AvatarLook; + use game::{AvatarLook, BuffStat}; use std::collections::BTreeMap; fn avatar() -> FieldAvatar { @@ -250,6 +249,7 @@ mod tests { y: 275, stance: 4, chair: 3010000, + buffs: Vec::new(), } } @@ -259,7 +259,11 @@ mod tests { expect.int(0).short(0).byte(0xFC).byte(1); expect.int(0); // morph expect.int(0); // buff mask (high) - expect.int(0); // buff mask (low) + empty_two_state(expect); + } + + fn empty_two_state(expect: &mut Expect<'_>) { + expect.int(0); // defense attack and state, then two-state data expect.int(0).short(0).bytes(&[0; 4]); // energy charge expect.int(0).bytes(&[0; 11]).short(0); // dash speed expect.bytes(&[0; 9]).int(0).short(0).byte(0); // dash jump @@ -302,6 +306,24 @@ mod tests { expect.end(); } + #[test] + fn spawn_player_carries_the_buffs_others_see() { + let mut avatar = avatar(); + avatar.buffs = vec![(BuffStat::Speed, 40), (BuffStat::DarkSight, 1)]; + let packet = build_player_enter_field(&avatar).unwrap(); + + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::SpawnPlayer as i16); + expect.int(0x0102_0304).byte(10).string("Tester"); + expect.string("").bytes(&[0; 6]); + expect + .long(0x01FC_0000_0000_0000) + .long((1 << 39) | (1 << 42)); + expect.byte(40); // speed; Dark Sight has no data + empty_two_state(&mut expect); + expect.short(100); + } + #[test] fn chair_packets_match_reference() { let shown = build_show_chair(0x0102_0304, 3010000).unwrap(); diff --git a/net/src/packet/handle/world/use_skill.rs b/net/src/packet/handle/world/use_skill.rs index c0de18d20..c48ba1cf1 100644 --- a/net/src/packet/handle/world/use_skill.rs +++ b/net/src/packet/handle/world/use_skill.rs @@ -3,17 +3,16 @@ use crate::handler::{ FieldRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, }; use crate::packet::build::world::stat::{self, StatUpdate}; -use crate::packet::build::world::{buff, skill}; +use crate::packet::build::world::{buff, inventory, skill}; use crate::skills::{self, Learned}; -use game::{Buff, BuffStat, Player}; +use game::{Buff, BuffStat, InventoryChange, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use std::time::{Duration, Instant}; use tracing::debug; -/// `USE_SKILL`: a skill that doesn't attack. Only the beginner buffs -/// (Recovery and Nimble Feet, for every beginner job) and Monster Magnet -/// do anything yet. Every answer unlocks the client, which waits on it +/// `USE_SKILL`: a skill that doesn't attack: the buffs (see +/// `skills::buffs`), Heal, and Monster Magnet. Every answer unlocks the client, which waits on it /// before using another skill or item. pub struct UseSkillHandler; @@ -137,7 +136,13 @@ impl WorldHandler for CancelBuffHandler { if ended.is_empty() { return Ok(HandlerResult::empty()); } - Ok(HandlerResult::reply(buff::build_cancel_buff(&ended)?)) + let result = HandlerResult::reply(buff::build_cancel_buff(&ended)?); + let unseen: Vec<_> = ended.into_iter().filter(|stat| stat.shown()).collect(); + if unseen.is_empty() { + return Ok(result); + } + let foreign = buff::build_cancel_foreign_buff(ctx.player.id(), &unseen)?; + Ok(result.with(WorldAction::BuffsChanged(foreign))) } } @@ -151,6 +156,8 @@ enum Refusal { Unsupported, CoolingDown, NotEnoughMp, + /// Without the item the skill costs. + NoItem, } /// Monster Magnet reaches at most this many mobs (0x96c215). @@ -241,25 +248,16 @@ fn heal(player: &mut Player, level: i32, party: u8, roll: f64) -> Result Option<(BuffStat, i32)> { - match skill.skill_id % 10_000_000 { - 1001 => Some((BuffStat::Recovery, skill.effect.x)), - 1002 => Some((BuffStat::Speed, skill.effect.speed)), - _ => None, - } -} - -/// A buff skill that was used. +/// A buff skill that was used: the stats it gave, and the items it cost. #[derive(Debug)] struct Used { skill: Learned, - stat: BuffStat, - value: i16, + stats: Vec<(BuffStat, i16)>, + items: Vec, } -/// Check the skill can be used now, then spend its MP and start its buff -/// and cooldown. +/// Check the skill can be used now, then spend its MP and items and start +/// its buffs and cooldown. fn use_skill( player: &mut Player, skill_id: i32, @@ -272,7 +270,7 @@ fn use_skill( let skill = skills::learned(player, skill_id) .filter(|skill| skill.level == level) .ok_or(Refusal::NotLearned)?; - let (stat, value) = buff_of(&skill).ok_or(Refusal::Unsupported)?; + let stats = skills::buffs(&skill).ok_or(Refusal::Unsupported)?; if player.buffs.cooling_down(skill_id, now) { return Err(Refusal::CoolingDown); } @@ -280,34 +278,61 @@ fn use_skill( if i32::from(player.stats.mp) < effect.mp_cost { return Err(Refusal::NotEnoughMp); } + let cost = effect.item_cost_count.max(1); + if effect.item_cost != 0 && player.inventory.count(effect.item_cost) < cost { + return Err(Refusal::NoItem); + } player.add_mp(-effect.mp_cost); - let value = value as i16; + let items = if effect.item_cost != 0 { + player + .inventory_mut() + .remove(effect.item_cost, cost as i16) + .map_err(|_| Refusal::NoItem)? + } else { + Vec::new() + }; let duration = Duration::from_secs(effect.duration.max(0) as u64); - player - .buffs - .give(Buff::new(skill_id, stat, value, now, duration)); + let stats: Vec<_> = stats + .into_iter() + .map(|(stat, value)| (stat, value as i16)) + .collect(); + for &(stat, value) in &stats { + player + .buffs + .give(Buff::new(skill_id, stat, value, now, duration)); + } if effect.cooldown > 0 { let length = Duration::from_secs(effect.cooldown as u64); player.buffs.start_cooldown(skill_id, now, length); } - Ok(Used { skill, stat, value }) + Ok(Used { + skill, + stats, + items, + }) } -/// The new MP, the buff, and the cooldown for the player; the skill's -/// effect for everyone else on the map. +/// The new MP, the items spent, the buff, and the cooldown for the player; +/// the skill's effect and the buffs they show for everyone else on the map. fn announce(player: &Player, used: &Used) -> WorldResult { - let Used { skill, stat, value } = used; + let Used { + skill, + stats, + items, + } = used; let effect = &skill.effect; let mut result = HandlerResult::reply(stat::build_stat_update( true, &[StatUpdate::Mp(player.stats.mp)], - )?) - .with_reply(buff::build_give_buff( + )?); + if !items.is_empty() { + result = result.with_reply(inventory::build_inventory_operation(false, items)?); + } + result = result.with_reply(buff::build_give_buff( skill.skill_id, effect.duration * 1000, - *stat, - *value, + stats, )?); if effect.cooldown > 0 { result = result.with_reply(buff::build_cooldown( @@ -321,7 +346,18 @@ fn announce(player: &Player, used: &Used) -> WorldResult { player.stats.level, skill.level as u8, )?; - Ok(result.with(WorldAction::Field(FieldRequest::Broadcast(shown)))) + result = result.with(WorldAction::Field(FieldRequest::Broadcast(shown))); + let given: Vec<_> = player + .buffs + .shown() + .into_iter() + .filter(|(stat, _)| stats.iter().any(|(given, _)| given == stat)) + .collect(); + if !given.is_empty() { + let foreign = buff::build_give_foreign_buff(player.id(), &given)?; + result = result.with(WorldAction::BuffsChanged(foreign)); + } + Ok(result) } #[cfg(test)] @@ -376,6 +412,7 @@ mod tests { .map(|action| match action { WorldAction::Reply(p) => p.opcode(), WorldAction::Field(FieldRequest::Broadcast(p)) => p.opcode(), + WorldAction::BuffsChanged(p) => p.opcode(), other => panic!("unexpected {other:?}"), }) .collect() @@ -401,6 +438,7 @@ mod tests { SendOpcode::GiveBuff as i16, SendOpcode::Cooldown as i16, SendOpcode::ShowForeignEffect as i16, + SendOpcode::GiveForeignBuff as i16, ] ); assert_eq!(player.stats.mp, 13); @@ -444,11 +482,75 @@ mod tests { opcodes(CancelBuffHandler.handle(&mut packet, &mut ctx)) }; assert_eq!(cancel(1001), Vec::::new()); - assert_eq!(cancel(1002), [SendOpcode::CancelBuff as i16]); + assert_eq!( + cancel(1002), + [ + SendOpcode::CancelBuff as i16, + SendOpcode::CancelForeignBuff as i16 + ] + ); assert_eq!(cancel(1002), Vec::::new()); assert!(player.buffs.active().is_empty()); } + #[test] + fn job_buffs_give_every_stat_of_their_level() { + if skills::data().is_err() { + return; + } + let now = Instant::now(); + let mut player = beginner_with(4101004, 20); + (player.stats.mp, player.stats.max_mp) = (200, 200); + let used = use_skill(&mut player, 4101004, 20, now).unwrap(); + assert_eq!(used.stats, [(BuffStat::Speed, 40), (BuffStat::Jump, 20)]); + assert_eq!(player.buffs.shown(), [(BuffStat::Speed, 40)]); + + // Hyper Body's own stats; nothing others see. + player.skills.set(SkillRecord { + skill_id: 1301007, + level: 30, + master_level: 0, + expiration: None, + }); + let used = use_skill(&mut player, 1301007, 30, now).unwrap(); + assert_eq!(used.stats, [(BuffStat::MaxHp, 60), (BuffStat::MaxMp, 60)]); + let sent = opcodes(announce(&player, &used)); + assert!(!sent.contains(&(SendOpcode::GiveForeignBuff as i16))); + } + + #[test] + fn shadow_partner_costs_a_summoning_rock() { + if skills::data().is_err() { + return; + } + let now = Instant::now(); + let mut player = beginner_with(4111002, 30); + (player.stats.mp, player.stats.max_mp) = (200, 200); + assert_eq!( + use_skill(&mut player, 4111002, 30, now).unwrap_err(), + Refusal::NoItem + ); + assert_eq!(player.stats.mp, 200); + player + .inventory_mut() + .add(game::Item::stack(4006001, 2), 100) + .unwrap(); + let used = use_skill(&mut player, 4111002, 30, now).unwrap(); + assert_eq!(used.stats, [(BuffStat::ShadowPartner, 80)]); + assert_eq!(player.inventory.count(4006001), 1); + let sent = opcodes(announce(&player, &used)); + assert_eq!( + sent, + [ + SendOpcode::StatChange as i16, + SendOpcode::InventoryOperation as i16, + SendOpcode::GiveBuff as i16, + SendOpcode::ShowForeignEffect as i16, + SendOpcode::GiveForeignBuff as i16, + ] + ); + } + #[test] fn recovery_heals_by_its_x_and_attacks_are_not_buffs() { if skills::data().is_err() { diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 988660874..3b4cafca4 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -59,6 +59,8 @@ pub enum SendOpcode { DamagePlayer = 0xC0, FacialExpression = 0xC1, ShowForeignEffect = 0xC6, + GiveForeignBuff = 0xC7, + CancelForeignBuff = 0xC8, UpdateCharLook = 0xC5, UpdatePartyMemberHp = 0xC9, ShowChair = 0xC4, diff --git a/net/src/skills.rs b/net/src/skills.rs index 4fa3734c4..e2aba149f 100644 --- a/net/src/skills.rs +++ b/net/src/skills.rs @@ -1,7 +1,7 @@ //! Skill templates (`Skill.nx`), loaded once. use crate::load::{self, asset_path}; -use game::{InventoryType, Player}; +use game::{BuffStat, InventoryType, Player}; use game_data::skills::{SkillData, SkillInfo, SkillLevel}; use std::sync::OnceLock; @@ -212,3 +212,63 @@ pub const TIMED: [i32; 2] = [2111002, 4211001]; pub fn prepares(skill_id: i32) -> bool { keydown(skill_id) || TIMED.contains(&skill_id) } + +/// The stats a buff skill gives at its level, or `None` for a skill that +/// isn't one. Every buff gives the stats its level names (attack, defense, +/// accuracy, avoidability, speed, jump), and some a stat of their own. A +/// weapon charge's own `mad` is the strength of its element, not a buff. +pub fn buffs(skill: &Learned) -> Option> { + let effect = &skill.effect; + let own = match skill.skill_id { + 1001 | 10001001 | 20001001 => vec![(BuffStat::Recovery, effect.x)], + 1002 | 10001002 | 20001002 => vec![], + // Iron Body, Rage, Iron Will, Magic Armor, Meditation, Bless, + // Focus, Haste. + 1001003 | 1101006 | 1301006 | 2001003 | 2101001 | 2201001 | 2301004 | 3001003 | 4101004 + | 4201003 => vec![], + 1101004 | 1101005 | 1201004 | 1201005 | 1301004 | 1301005 | 2111005 | 2211005 | 3101002 + | 3201002 | 4101003 | 4201002 | 5101006 | 5201003 => { + vec![(BuffStat::Booster, effect.x)] + } + 1101007 | 1201007 => vec![(BuffStat::PowerGuard, effect.x)], + // The orbs start at none, which the client counts from 1. + 1111002 => vec![(BuffStat::Combo, 1)], + 1211003..=1211008 | 1221003 | 1221004 => { + return Some(vec![(BuffStat::WeaponCharge, effect.x)]); + } + 1301007 => vec![(BuffStat::MaxHp, effect.x), (BuffStat::MaxMp, effect.y)], + 1311008 => vec![(BuffStat::DragonBlood, effect.x)], + 1121000 | 1221000 | 1321000 | 2121000 | 2221000 | 2321000 | 3121000 | 3221000 | 4121000 + | 4221000 | 5121000 | 5221000 => vec![(BuffStat::MapleWarrior, effect.x)], + 1121002 | 1221002 | 1321002 => vec![(BuffStat::Stance, effect.prop)], + 2001002 => vec![(BuffStat::MagicGuard, effect.x)], + 2301003 => vec![(BuffStat::Invincible, effect.x)], + 2311003 => vec![(BuffStat::HolySymbol, effect.x)], + 2121004 | 2221004 | 2321004 => vec![(BuffStat::Infinity, effect.x)], + 3101004 | 3201004 => vec![(BuffStat::SoulArrow, effect.x)], + 3121008 => vec![(BuffStat::Concentrate, effect.x)], + 3121002 | 3221002 => vec![(BuffStat::SharpEyes, effect.x << 8 | effect.y)], + 4001003 => vec![(BuffStat::DarkSight, effect.x)], + 4111001 => vec![(BuffStat::MesoUp, effect.x)], + 4111002 => vec![(BuffStat::ShadowPartner, effect.x)], + 4211003 => vec![(BuffStat::PickPocket, effect.x)], + 4211005 => vec![(BuffStat::MesoGuard, effect.x)], + _ => return None, + }; + let leveled = [ + (BuffStat::Pad, effect.pad), + (BuffStat::Pdd, effect.pdd), + (BuffStat::Mad, effect.magic), + (BuffStat::Mdd, effect.mdd), + (BuffStat::Acc, effect.acc), + (BuffStat::Eva, effect.eva), + (BuffStat::Speed, effect.speed), + (BuffStat::Jump, effect.jump), + ]; + let mut stats: Vec<_> = leveled + .into_iter() + .filter(|&(_, value)| value != 0) + .collect(); + stats.extend(own); + Some(stats) +} diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index c7b8c35cc..b5672d88c 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -342,6 +342,7 @@ impl ClientActor { }) .await? } + WorldAction::BuffsChanged(announce) => self.show_buffs(announce).await?, WorldAction::Whisper(delivery) => { self.send_to_directory(DirectoryMessage::Whisper { from: self.client_id, diff --git a/runtime/src/actor/client/buff.rs b/runtime/src/actor/client/buff.rs index 05cb961d5..420593efc 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -1,7 +1,10 @@ use super::ClientActor; use crate::error::RuntimeError; +use crate::message::FieldMessage; +use game::BuffStat; use net::packet::build::world::buff; use net::packet::build::world::stat::{self, StatUpdate}; +use packet::Packet; impl ClientActor { /// When the player's buffs next need `update_buffs`. @@ -26,7 +29,31 @@ impl ClientActor { for skill_id in tick.cooled { packets.push(buff::build_cooldown(skill_id, 0)?); } - self.send_all(&mut packets).await + self.send_all(&mut packets).await?; + self.hide_buffs(&tick.expired).await + } + + /// Show the others on the map a change to the buffs they see, and keep + /// the player's field avatar up to date for those who arrive later. + pub(super) async fn show_buffs(&mut self, announce: Packet) -> Result<(), RuntimeError> { + let avatar = self.in_game()?.player.field_avatar(0, 0, 0); + self.send_to_field(FieldMessage::UpdateAvatar { + client_id: self.client_id, + avatar, + announce: Some(announce), + }) + .await + } + + /// Tell the others which of the buffs they saw ended. + async fn hide_buffs(&mut self, ended: &[BuffStat]) -> Result<(), RuntimeError> { + let unseen: Vec<_> = ended.iter().copied().filter(|stat| stat.shown()).collect(); + if unseen.is_empty() { + return Ok(()); + } + let id = self.in_game()?.player.id(); + let announce = buff::build_cancel_foreign_buff(id, &unseen)?; + self.show_buffs(announce).await } /// End every buff now, e.g. on death. Buffs don't follow the player to @@ -37,6 +64,7 @@ impl ClientActor { return Ok(()); } let mut packet = buff::build_cancel_buff(&ended)?; - self.send(&mut packet).await + self.send(&mut packet).await?; + self.hide_buffs(&ended).await } } diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index ed525b078..ee7420a59 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -651,6 +651,11 @@ impl FieldActor { if self.occupants.remove(&client_id).is_none() { return; } + // Everyone sees the player go before control passes on. + match build_player_leave_field(client_id) { + Ok(packet) => self.broadcast(None, packet), + Err(error) => warn!(client_id, %error, "Failed to build leave packet"), + } let next = self.occupants.keys().next().copied(); let handed = self.mobs.release(client_id, next); if let Some(next) = next { @@ -662,10 +667,6 @@ impl FieldActor { self.send_npc_control(next); } } - match build_player_leave_field(client_id) { - Ok(packet) => self.broadcast(None, packet), - Err(error) => warn!(client_id, %error, "Failed to build leave packet"), - } } fn handle_npc_interaction(&self, from: ClientId, object_id: i32, request_id: u64) { @@ -750,6 +751,7 @@ mod tests { y, stance: 2, chair: 0, + buffs: Vec::new(), } } From e845dee1b9e76fe7baf124c55b0c22c50388b56b Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 03:10:37 -0400 Subject: [PATCH 2/3] Shadow Partner's second set of bullets (L152) Locally a ranged attack with Shadow Partner (not Taunt or Vampire) has twice the hits, at most 15, and throws its bullets again from 30 px behind the user, below the first set. Other users' shots read their Shadow Partner foreign buff: twice the bullets and shown hits, the client's start choice by mob index, and a miss's ends 30 px short. --- HeavenClient/Gameplay/Combat/Attack.h | 4 +++ HeavenClient/Gameplay/Combat/Combat.cpp | 40 ++++++++++++++++++----- HeavenClient/Gameplay/Combat/Combat.h | 2 +- HeavenClient/Gameplay/Combat/RemoteShot.h | 16 +++++++++ HeavenClient/Gameplay/Combat/ShotPath.h | 19 +++++++++++ HeavenClient/tests/remote_shot.cpp | 7 ++++ HeavenClient/tests/shot_path.cpp | 8 +++++ 7 files changed, 86 insertions(+), 10 deletions(-) diff --git a/HeavenClient/Gameplay/Combat/Attack.h b/HeavenClient/Gameplay/Combat/Attack.h index 5c8e29536..f1cc43644 100644 --- a/HeavenClient/Gameplay/Combat/Attack.h +++ b/HeavenClient/Gameplay/Combat/Attack.h @@ -66,6 +66,8 @@ namespace ms int32_t healed = 0; uint8_t hitcount = 0; + // Shadow Partner's second set of bullets makes 2. + uint8_t sets = 1; uint8_t mobcount = 0; uint8_t speed = 0; uint8_t stance = 0; @@ -130,6 +132,7 @@ namespace ms { type = attack.type; hitcount = attack.hitcount; + sets = attack.sets; skill = attack.skill; charge = attack.charge; speed = attack.speed; @@ -146,6 +149,7 @@ namespace ms int32_t attacker = 0; uint8_t mobcount = 0; uint8_t hitcount = 1; + uint8_t sets = 1; int32_t skill = 0; int32_t charge = 0; int32_t bullet = 0; diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 653ba6e26..f28f4a97a 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -492,6 +492,14 @@ namespace ms move.apply_stats(player, attack); + // Shadow Partner throws each bullet again, for twice the hits. + if (attack.type == Attack::Type::RANGED && player.has_buff(Buffstat::Id::SHADOWPARTNER) && + shot_path::partner_shoots(attack.skill)) + { + attack.sets = 2; + attack.hitcount = shot_path::partner_hits(attack.hitcount); + } + if (whack != Stance::Id::NONE) attack.range = point_blank::range(player.get_afterimage_range(whack)); // Like v83, shoot from the first stack that covers the cost. @@ -1011,18 +1019,25 @@ namespace ms uint16_t delay = user.get_attackdelay(0); bool spell = result.type == Attack::Type::MAGIC; + // Shadow Partner's set follows the user's, from behind them, each + // bullet further below the last. auto shoot = [&](Point target, uint8_t count) { + uint8_t sets = result.sets; + if (spell) - count = 1; + count = sets = 1; else if (shot_path::hides_bullet(result.skill)) return; + uint8_t per_set = std::max(count / sets, 1); + for (uint8_t i = 0; i < count; i++) { - Point end = target + Point(0, shot_path::spread(i, count)); + Point from = i < per_set ? start : shot_path::partner_start(start, result.toleft); + Point end = target + Point(0, shot_path::spread(i, per_set)); uint16_t time = delay + shot_path::bullet_delay(result.bullet, result.skill, i); - bulleteffects.push(time, Bullet(animation, start, end, shot_path::flight_time(start, end))); + bulleteffects.push(time, Bullet(animation, from, end, shot_path::flight_time(from, end))); } }; @@ -1226,7 +1241,7 @@ namespace ms } } - void Combat::show_remote_shot(const Char &user, const SpecialMove &move, const AttackResult &result, + void Combat::show_remote_shot(const OtherChar &user, const SpecialMove &move, const AttackResult &result, Rectangle range) { AttackUser attackuser = { @@ -1252,6 +1267,8 @@ namespace ms lt_rb = stats.range; } + // Shadow Partner throws every bullet twice (0x981447). + uint8_t sets = user.get_buff(Buffstat::Id::SHADOWPARTNER) ? 2 : 1; Animation animation = move.get_bullet(user, result.bullet); Point position = user.get_position(); uint16_t delay = remote_shot::attack_delay(skill, user.get_attackdelay(0)); @@ -1296,7 +1313,7 @@ namespace ms bodies.push_back(body); std::vector numbers = place_numbers(line.second, move.get_id()); - size_t shown = remote_shot::shown_hits(numbers.size(), bullets, attacks); + size_t shown = remote_shot::shown_hits(numbers.size(), sets * bullets, attacks); for (size_t i = 0; i < shown; i++) damageeffects.emplace( @@ -1318,16 +1335,17 @@ namespace ms // Each bullet flies as long as the hits take to land, to at most // 400 px along the way, each a few px below the last. Point end = remote_shot::end(start, aim); + Point from = remote_shot::partner_starts(skill, target, bullets, sets) + ? shot_path::partner_start(start, result.toleft) : start; - for (uint8_t i = 0; i < bullets; i++) + for (uint8_t i = 0; i < sets * bullets; i++) { Point to = end + Point(0, shot_path::spread(i, bullets)); uint16_t time = delay + shot_path::bullet_delay(result.bullet, skill, i); - bulleteffects.push(time, Bullet(animation, start, to, hit)); + bulleteffects.push(time, Bullet(animation, from, to, hit)); } } - if (first_hit) if (first_hit) rain_shot(skill, lt_rb, position, result.toleft, delay + first, *first_hit, bodies); @@ -1342,7 +1360,11 @@ namespace ms int16_t end_x = position.x() + (result.toleft ? -reach : reach); uint16_t flight = remote_shot::miss_flight(skill, start.x(), end_x); - for (uint8_t i = 0; i < bullets; i++) + // With Shadow Partner they all stop 30 px short, as fast. + if (sets > 1) + end_x = shot_path::partner_start(Point(end_x, y), result.toleft).x(); + + for (uint8_t i = 0; i < sets * bullets; i++) { Point end(end_x, static_cast(y + remote_shot::miss_spread(i, bullets))); uint16_t time = delay + shot_path::bullet_delay(result.bullet, skill, i); diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index beea861c7..f3f6d989d 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -258,7 +258,7 @@ namespace ms // Show another user's shot: its bullets, and its hits as they would // get there (FUN_00981211). - void show_remote_shot(const Char &user, const SpecialMove &move, const AttackResult &result, + void show_remote_shot(const OtherChar &user, const SpecialMove &move, const AttackResult &result, Rectangle range); // Draw Chain Lightning's bolt from the user through its targets, each diff --git a/HeavenClient/Gameplay/Combat/RemoteShot.h b/HeavenClient/Gameplay/Combat/RemoteShot.h index c6a3a79d1..d363bc2b2 100644 --- a/HeavenClient/Gameplay/Combat/RemoteShot.h +++ b/HeavenClient/Gameplay/Combat/RemoteShot.h @@ -227,5 +227,21 @@ namespace ms { return static_cast(8 * bullet - 4 * count + 4); } + + // Whether the bullets at the `target`th mob fly from behind the user, + // with Shadow Partner (`sets` 2). The client compares the mob's + // index, not the bullet's, with the bullets in a set, so with one + // bullet all of them do, and with more none at the first mob + // (0x981ae3). Battleship Torpedo's only check the bullets in a set + // (0x9819fd). + inline bool partner_starts(int32_t skill, size_t target, uint8_t bullets, uint8_t sets) + { + if (sets < 2) + return false; + + size_t per_set = bullets / sets; + + return skill == 5221008 ? per_set == 0 : target >= per_set; + } } } diff --git a/HeavenClient/Gameplay/Combat/ShotPath.h b/HeavenClient/Gameplay/Combat/ShotPath.h index 7bceb3177..4d5a46d58 100644 --- a/HeavenClient/Gameplay/Combat/ShotPath.h +++ b/HeavenClient/Gameplay/Combat/ShotPath.h @@ -512,6 +512,25 @@ namespace ms return static_cast(7 + 14 * bullet - 7 * count); } + // Whether Shadow Partner throws a shot's bullets again: not for + // Taunt, and never with Vampire (0x953b2a). + inline bool partner_shoots(int32_t skill) + { + return skill != 4121003 && skill != 14101006; + } + + // A shot's hits with Shadow Partner: twice as many, at most 15. + inline uint8_t partner_hits(uint8_t hitcount) + { + return static_cast(std::min(hitcount * 2, 15)); + } + + // Where Shadow Partner's bullets start: 30 px behind the user's. + inline Point partner_start(Point start, bool toleft) + { + return start + Point(toleft ? 30 : -30, 0); + } + // How long after the first bullet the `bullet`th one is thrown or // fired (FUN_00942831). inline uint16_t bullet_delay(int32_t bullet_id, int32_t skill, uint8_t bullet) diff --git a/HeavenClient/tests/remote_shot.cpp b/HeavenClient/tests/remote_shot.cpp index a2be2009a..2c213cfed 100644 --- a/HeavenClient/tests/remote_shot.cpp +++ b/HeavenClient/tests/remote_shot.cpp @@ -65,6 +65,13 @@ int main() require(miss_spread(0, 1) == 0 && miss_spread(0, 2) == -4 && miss_spread(1, 2) == 4, "its bullets 8 px apart around the row"); + require(!partner_starts(0, 5, 1, 1), "without Shadow Partner every shot is the user's"); + require(partner_starts(0, 0, 1, 2) && partner_starts(0, 3, 1, 2), "one bullet: all from behind"); + require(!partner_starts(0, 0, 2, 2) && partner_starts(0, 1, 2, 2) && !partner_starts(0, 0, 3, 2), + "more: the first mob's from in front, the rest's from behind"); + require(!partner_starts(5221008, 4, 2, 2) && partner_starts(5221008, 0, 1, 2), + "a torpedo's only by its bullets"); + std::cout << "Remote shot passed\n"; } catch (const std::exception &error) diff --git a/HeavenClient/tests/shot_path.cpp b/HeavenClient/tests/shot_path.cpp index 843cdb494..e3e8cdbd3 100644 --- a/HeavenClient/tests/shot_path.cpp +++ b/HeavenClient/tests/shot_path.cpp @@ -38,6 +38,14 @@ int main() require(spread(0, 1) == 0, "a single bullet flies at its target"); require(spread(0, 2) == -7 && spread(1, 2) == 7, "two bullets fly 7 px either side"); require(spread(0, 3) == -14 && spread(2, 3) == 14, "three bullets fly 14 px apart"); + require(spread(2, 1) == 28 && spread(3, 2) == 35, "Shadow Partner's set goes on below the user's"); + + require(partner_shoots(4001344) && !partner_shoots(4121003) && !partner_shoots(14101006), + "Shadow Partner throws again, but not for Taunt or Vampire"); + require(partner_hits(1) == 2 && partner_hits(3) == 6 && partner_hits(8) == 15, "twice the hits, at most 15"); + require(partner_start({ 100, 50 }, false) == ms::Point(70, 50) && + partner_start({ 100, 50 }, true) == ms::Point(130, 50), + "from 30 px behind the user"); require(bullet_delay(2070000, 0, 1) == 120, "throwing stars leave 120 ms apart"); require(bullet_delay(5021000, 0, 2) == 240, "cash shop stars do too"); From b1a1d4f66b77a62ff8e86035a2ddbd4e3ec467f4 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 07:09:02 -0400 Subject: [PATCH 3/3] L145: party buffs and party Heal USE_SKILL's party mask now reaches the members it names: the directory maps slot bits to party members on the caster's map, and each applies the buffs (or Heal's share) when alive and within the skill's lt/rb plus 100 px, with user effect 2 showing the skill's affected animation. HC plays that effect on 0xC6 and 0xCE type 2 instead of the use effect. --- HeavenClient/Character/ActiveBuffs.cpp | 2 + HeavenClient/Gameplay/Combat/Combat.cpp | 15 + HeavenClient/Gameplay/Combat/Combat.h | 5 + HeavenClient/Gameplay/Combat/Skill.cpp | 6 + HeavenClient/Gameplay/Combat/Skill.h | 5 + .../Net/Handlers/MapObjectHandlers.cpp | 7 + .../Net/Handlers/MessagingHandlers.cpp | 7 + agent_docs/combat.md | 18 +- game-data/src/lib.rs | 18 + game-data/src/skills.rs | 10 + net/src/handler.rs | 14 + net/src/lib.rs | 4 +- net/src/packet/build/world/buff.rs | 41 +++ net/src/packet/handle/world/mod.rs | 3 +- net/src/packet/handle/world/use_skill.rs | 339 ++++++++++++++++-- net/src/skills.rs | 158 ++++++++ runtime/src/actor/client.rs | 14 + runtime/src/actor/client/buff.rs | 34 ++ runtime/src/actor/directory.rs | 75 +++- runtime/src/message.rs | 16 +- 20 files changed, 751 insertions(+), 40 deletions(-) diff --git a/HeavenClient/Character/ActiveBuffs.cpp b/HeavenClient/Character/ActiveBuffs.cpp index cac0fe433..3cf1bbe54 100644 --- a/HeavenClient/Character/ActiveBuffs.cpp +++ b/HeavenClient/Character/ActiveBuffs.cpp @@ -60,6 +60,8 @@ namespace ms buffs[Buffstat::Id::MDEF] = std::make_unique>(); buffs[Buffstat::Id::SPEED] = std::make_unique>(); buffs[Buffstat::Id::JUMP] = std::make_unique>(); + buffs[Buffstat::Id::ACC] = std::make_unique>(); + buffs[Buffstat::Id::AVOID] = std::make_unique>(); buffs[Buffstat::Id::HYPERBODYHP] = std::make_unique>(); buffs[Buffstat::Id::HYPERBODYMP] = std::make_unique>(); } diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index f28f4a97a..a2106f900 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -1660,6 +1660,21 @@ namespace ms get_move(skillid).apply_useeffects(player); } + void Combat::show_affected(int32_t cid, int32_t skillid) + { + if (Optional ouser = chars.get_char(cid)) + { + const SkillPrepare::Effect &affected = get_skill(skillid).get_affected(); + ouser->show_attack_effect(affected.animation, affected.z); + } + } + + void Combat::show_player_affected(int32_t skillid) + { + const SkillPrepare::Effect &affected = get_skill(skillid).get_affected(); + player.show_attack_effect(affected.animation, affected.z); + } + void Combat::show_prepare(int32_t cid, int32_t skillid, uint8_t level, uint16_t action_and_facing, uint8_t speed) { Optional ouser = chars.get_char(cid); diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index f3f6d989d..575f99747 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -91,6 +91,11 @@ namespace ms // Show a buff effect. void show_player_buff(int32_t skillid); + // Show a party member's skill reaching another user, or the player + // (user effect 2, 0x937b83). + void show_affected(int32_t cid, int32_t skillid); + void show_player_affected(int32_t skillid); + // Show Monster Magnet catching a mob, or failing to, over it // (0x66d6b9, 0x438eb6). void show_catch(int32_t oid, bool success); diff --git a/HeavenClient/Gameplay/Combat/Skill.cpp b/HeavenClient/Gameplay/Combat/Skill.cpp index 9eebf1cd0..db3e0d0ea 100644 --- a/HeavenClient/Gameplay/Combat/Skill.cpp +++ b/HeavenClient/Gameplay/Combat/Skill.cpp @@ -51,6 +51,7 @@ namespace ms prepare.keydown = { src["keydown"], src["keydown"]["z"] }; prepare.keydownend = { src["keydownend"], src["keydownend"]["z"] }; special = src["special"]; + affected = { src["affected"], src["affected"]["z"] }; if (shot_rain::kind(skillid) != shot_rain::Kind::NONE || spell_area::kind(skillid) != spell_area::Kind::NONE) { @@ -332,6 +333,11 @@ namespace ms return special; } + const SkillPrepare::Effect &Skill::get_affected() const + { + return affected; + } + const SkillRain &Skill::get_rain() const { return rain; diff --git a/HeavenClient/Gameplay/Combat/Skill.h b/HeavenClient/Gameplay/Combat/Skill.h index 263011d25..2fa603931 100644 --- a/HeavenClient/Gameplay/Combat/Skill.h +++ b/HeavenClient/Gameplay/Combat/Skill.h @@ -93,6 +93,10 @@ namespace ms // The effect shown where a thrown grenade goes off. Animation get_special() const; + // What a party member the skill reached shows (its `affected` + // node, 0x93632a). + const SkillPrepare::Effect &get_affected() const; + // What the skill rains once its shot hits, if it's one that does. const SkillRain &get_rain() const; @@ -118,6 +122,7 @@ namespace ms std::unique_ptr useeffect; std::unique_ptr hiteffect; SkillPrepare prepare; + SkillPrepare::Effect affected; Animation special; SkillRain rain; diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index f04c494a9..04a037b3f 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -227,6 +227,13 @@ namespace ms { Stage::get().show_character_effect(cid, CharEffect::MONSTER_CARD); } + else if (effect == 2) // a party member's skill reached them + { + int32_t skillid = recv.read_int(); + recv.read_byte(); // level + + Stage::get().get_combat().show_affected(cid, skillid); + } else if (recv.available()) // skill { int32_t skillid = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index 815f8c2b5..c135413e6 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -592,6 +592,13 @@ namespace ms { Stage::get().get_player().show_effect_id(CharEffect::Id::MONSTER_CARD); } + else if (mode1 == 2) // a party member's skill reached the player + { + int32_t skillid = recv.read_int(); + recv.read_byte(); // level + + Stage::get().get_combat().show_player_affected(skillid); + } else if (mode1 == 18) // intro effect { recv.read_string(); // path diff --git a/agent_docs/combat.md b/agent_docs/combat.md index b8b140dfe..de5e9c83e 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -230,9 +230,9 @@ once past beginner. ## Buff skills USE_SKILL (`0x5B`, `handle::world::use_skill`) casts a skill that doesn't -attack. Only the beginner buffs do anything yet (Recovery and Nimble Feet, by -`skill_id % 10_000_000`, so Noblesse and Legend copies work too); others are -ignored. The level the client names must be the learned one, the skill must +attack. `net::skills::buffs` maps each buff skill to the stats it gives (the +level's attack, defense, accuracy, avoidability, speed and jump, plus the +skill's own stat); a skill missing from it is ignored. The level the client names must be the learned one, the skill must be off cooldown, and the player alive with enough MP. The caster gets the MP left, GIVE_BUFF (the temporary-stat masks match HeavenMS: Speed and Recovery are both second-mask bits), and COOLDOWN when the level has a `cooltime`; @@ -247,6 +247,18 @@ icon sends CANCEL_BUFF (`0x5C`, the skill id), which ends that skill's buffs and answers with CANCEL_BUFF. Dying and changing channel end every buff; cooldowns keep running. +A skill that reaches the party (`skills::reaches_party`) carries a slot mask +after the level: 0x80 alone, else bit `1 << (5 - slot)` for each member the +client found in the skill's area, the caster included, with slots in joining +order. The handler passes the skill on as `WorldAction::PartySkill`; the +directory sends it to the members in those slots on the caster's map, and +each member's client actor applies it when they are alive, out of the Cash +Shop, and within the level's `lt`/`rb` around the caster plus 100 px +(`skills::in_area`). Members get the buffs without MP or cooldown, or for +Heal the caster's per-member share of HP, and SHOW_ITEM_GAIN_INCHAT / +SHOW_FOREIGN_EFFECT type 2 (`skill, level`), which plays the skill's +`affected` animation on them. + ## Spending AP and SP The client locks its UI after SPEND_AP or SPEND_SP until the server answers, diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index f95157906..ecf2f47d4 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -660,6 +660,24 @@ impl NxDataFile { }) } + /// A vector child's x and y. + fn vector_child( + &self, + parent_index: u32, + key: &str, + ) -> Result, GameDataError> { + let Some(child_index) = self.child_by_name(parent_index, key)? else { + return Ok(None); + }; + let node = self.node_at(child_index)?; + if node.data_type != 4 { + return Ok(None); + } + let x = node.data as u32 as i32; + let y = (node.data >> 32) as u32 as i32; + Ok(Some((x, y))) + } + /// A real (or integer) child as `f64`. fn real_child(&self, parent_index: u32, key: &str) -> Result, GameDataError> { let Some(child_index) = self.child_by_name(parent_index, key)? else { diff --git a/game-data/src/skills.rs b/game-data/src/skills.rs index d10de1e9d..5898b09e6 100644 --- a/game-data/src/skills.rs +++ b/game-data/src/skills.rs @@ -72,6 +72,10 @@ pub struct SkillLevel { /// Shadow Partner's Summoning Rock. pub item_cost: i32, pub item_cost_count: i32, + /// The skill's area around the user facing left (`lt`, `rb`), (0, 0) + /// for one without. + pub lt: (i32, i32), + pub rb: (i32, i32), } pub struct SkillData { @@ -133,6 +137,8 @@ impl SkillData { prop: int("prop")?, item_cost: int("itemCon")?, item_cost_count: int("itemConNo")?, + lt: nx.vector_child(level, "lt")?.unwrap_or_default(), + rb: nx.vector_child(level, "rb")?.unwrap_or_default(), }); } } @@ -202,6 +208,10 @@ mod tests { let avenger = data.info(4111005).unwrap().unwrap().levels[29].clone(); assert_eq!((avenger.bullet_count, avenger.bullet_consume), (1, 3)); + let bless = data.info(2301004).unwrap().unwrap().levels[19].clone(); + assert_eq!((bless.lt, bless.rb), ((-300, -200), (300, 200))); + assert_eq!(three_snails.levels[0].lt, (0, 0)); + let maple_warrior = data.info(2121000).unwrap().unwrap(); assert!(maple_warrior.invisible); assert_eq!(maple_warrior.max_level, 30); diff --git a/net/src/handler.rs b/net/src/handler.rs index cc37781bd..5d1fb2749 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -303,6 +303,8 @@ pub enum WorldAction { Died, Whisper(WhisperDelivery), Party(PartyRequest), + /// A skill the player used reached party members; see `PartySkill`. + PartySkill(PartySkill), Buddy(BuddyRequest), /// Tell the player where a character is (`/find`). Find { @@ -321,6 +323,18 @@ pub enum WorldAction { Disconnect, } +/// A skill a player used that reached their party: the members in the +/// slots set in `slots` (a bit each, the first slot highest, as USE_SKILL +/// has them) get its buffs, or `heal` HP from Heal, if they are alive and +/// in its area. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PartySkill { + pub skill_id: i32, + pub level: i32, + pub slots: u8, + pub heal: i32, +} + /// A request a player makes of their field, which owns its mobs. #[derive(Debug)] pub enum FieldRequest { diff --git a/net/src/lib.rs b/net/src/lib.rs index 6512cae9c..2c5484e1c 100644 --- a/net/src/lib.rs +++ b/net/src/lib.rs @@ -34,8 +34,8 @@ pub use self::game_data::{closest_spawn_point, get as get_game_data, spawn_posit pub use self::handler::{ login_route, world_route, BroadcastScope, BuddyRequest, ClientId, FieldRequest, HandlerResult, LoginAction, LoginContext, LoginHandler, LoginStage, MessengerRequest, PartyRequest, - PreGameHandler, SpawnPosition, WhisperDelivery, WorldAction, WorldContext, WorldHandler, - WorldRoute, + PartySkill, PreGameHandler, SpawnPosition, WhisperDelivery, WorldAction, WorldContext, + WorldHandler, WorldRoute, }; pub use self::io::error; pub use self::npc_data::get as get_npc_dialogue_data; diff --git a/net/src/packet/build/world/buff.rs b/net/src/packet/build/world/buff.rs index e347ce6aa..b16e83d57 100644 --- a/net/src/packet/build/world/buff.rs +++ b/net/src/packet/build/world/buff.rs @@ -157,6 +157,32 @@ pub fn build_skill_effect( Ok(p) } +/// SHOW_ITEM_GAIN_INCHAT 2: a party member's skill reached the player, +/// who plays its `affected` animation (0x93632a). +pub fn build_own_affected(skill_id: i32, skill_level: u8) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::ShowItemGainInChat as i16)?; + p.write_byte(2)?; + p.write_int(skill_id)?; + p.write_byte(skill_level)?; + Ok(p) +} + +/// SHOW_FOREIGN_EFFECT 2: the same, on another player. +pub fn build_foreign_affected( + character_id: i32, + skill_id: i32, + skill_level: u8, +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::ShowForeignEffect as i16)?; + p.write_int(character_id)?; + p.write_byte(2)?; + p.write_int(skill_id)?; + p.write_byte(skill_level)?; + Ok(p) +} + /// The skill effect of a Monster Magnet, which also says which way the /// player faced (0x937b42). pub fn build_magnet_effect( @@ -249,4 +275,19 @@ mod tests { expect.byte(1).int(1001).byte(12).byte(3); expect.end(); } + + #[test] + fn affected_effects_carry_the_skill_and_its_level() { + let packet = build_own_affected(2301004, 20).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::ShowItemGainInChat as i16); + expect.byte(2).int(2301004).byte(20); + expect.end(); + + let packet = build_foreign_affected(7, 2301004, 20).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::ShowForeignEffect as i16).int(7); + expect.byte(2).int(2301004).byte(20); + expect.end(); + } } diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 5bdff5162..1626d3f35 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -60,6 +60,7 @@ pub use self::script_portal::ChangeMapSpecialHandler; pub use self::spend_points::{SpendApHandler, SpendSpHandler}; pub use self::use_item::{HealOverTimeHandler, UseItemHandler}; pub use self::use_skill::{ - CancelBuffHandler, SkillEffectHandler, ThrowGrenadeHandler, UseSkillHandler, + party_skill_reached, CancelBuffHandler, SkillEffectHandler, ThrowGrenadeHandler, + UseSkillHandler, }; pub use self::whisper::WhisperHandler; diff --git a/net/src/packet/handle/world/use_skill.rs b/net/src/packet/handle/world/use_skill.rs index c48ba1cf1..a721fcf9a 100644 --- a/net/src/packet/handle/world/use_skill.rs +++ b/net/src/packet/handle/world/use_skill.rs @@ -1,6 +1,6 @@ use crate::error::NetworkError; use crate::handler::{ - FieldRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, + FieldRequest, HandlerResult, PartySkill, WorldAction, WorldContext, WorldHandler, WorldResult, }; use crate::packet::build::world::stat::{self, StatUpdate}; use crate::packet::build::world::{buff, inventory, skill}; @@ -13,7 +13,9 @@ use tracing::debug; /// `USE_SKILL`: a skill that doesn't attack: the buffs (see /// `skills::buffs`), Heal, and Monster Magnet. Every answer unlocks the client, which waits on it -/// before using another skill or item. +/// before using another skill or item. A skill that reaches the party +/// gives its buffs, or Heal its HP, to the members the client found around +/// the player too. pub struct UseSkillHandler; impl WorldHandler for UseSkillHandler { @@ -39,12 +41,13 @@ impl WorldHandler for UseSkillHandler { if skill_id == skills::HEAL { let party = r.read_byte()?; return match heal(ctx.player, level, party, rand::random()) { - Ok(hp) => { + Ok((hp, amount)) => { let mp = ctx.player.stats.mp; - Ok(HandlerResult::reply(stat::build_stat_update( + let result = HandlerResult::reply(stat::build_stat_update( true, &[StatUpdate::Hp(hp), StatUpdate::Mp(mp)], - )?)) + )?); + Ok(with_party(result, skill_id, level, Some(party), amount)) } Err(refusal) => { debug!(skill_id, level, ?refusal, "Heal refused"); @@ -52,8 +55,15 @@ impl WorldHandler for UseSkillHandler { } }; } + let party = read_party(&mut r, skill_id); match use_skill(ctx.player, skill_id, level, Instant::now()) { - Ok(used) => announce(ctx.player, &used), + Ok(used) => Ok(with_party( + announce(ctx.player, &used)?, + skill_id, + level, + party, + 0, + )), Err(refusal) => { debug!(skill_id, level, ?refusal, "Skill use refused"); Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)) @@ -160,6 +170,46 @@ enum Refusal { NoItem, } +/// The party bits USE_SKILL carries after the level (0x96d399), for a skill +/// that reaches the party: after where the player stands, for a skill that +/// sends it, and Shadow Stars' star. None for any other skill, or a packet +/// cut short. +fn read_party(r: &mut Cursor<&Vec>, skill_id: i32) -> Option { + if !skills::reaches_party(skill_id) { + return None; + } + if skills::sends_position(skill_id) { + r.read_short().ok()?; + r.read_short().ok()?; + } + if skill_id == skills::SHADOW_STARS { + r.read_int().ok()?; + } + r.read_byte().ok() +} + +/// Pass a used skill on to the party members it reached besides the +/// player, if any. +fn with_party( + result: HandlerResult, + skill_id: i32, + level: i32, + party: Option, + heal: i32, +) -> HandlerResult { + match party { + Some(slots) if slots != skills::ALONE && slots != 0 => { + result.with(WorldAction::PartySkill(PartySkill { + skill_id, + level, + slots, + heal, + })) + } + _ => result, + } +} + /// Monster Magnet reaches at most this many mobs (0x96c215). const MAGNET_MOBS: i32 = 15; @@ -229,10 +279,10 @@ fn announce_magnet( } /// Heal comes before its attack, with a bit for each party member it -/// reaches (0x96a69d), or 0x80 without a party. It heals the cleric by each -/// one's share; the party members besides them aren't healed yet. It spends -/// the MP, so the attack doesn't. -fn heal(player: &mut Player, level: i32, party: u8, roll: f64) -> Result { +/// reaches (0x96a69d), or 0x80 without a party. It heals the cleric, and +/// each member it reached, by one's share: the cleric's new HP and the +/// share. It spends the MP, so the attack doesn't. +fn heal(player: &mut Player, level: i32, party: u8, roll: f64) -> Result<(i16, i32), Refusal> { if player.stats.hp <= 0 { return Err(Refusal::Dead); } @@ -244,8 +294,8 @@ fn heal(player: &mut Player, level: i32, party: u8, roll: f64) -> Result = stats - .into_iter() - .map(|(stat, value)| (stat, value as i16)) - .collect(); - for &(stat, value) in &stats { - player - .buffs - .give(Buff::new(skill_id, stat, value, now, duration)); - } + let stats = give(player, &skill, stats, now); if effect.cooldown > 0 { let length = Duration::from_secs(effect.cooldown as u64); player.buffs.start_cooldown(skill_id, now, length); @@ -313,6 +354,83 @@ fn use_skill( }) } +/// Start a skill's buffs on the player: the stats it gives, as GIVE_BUFF +/// carries them. +fn give( + player: &mut Player, + skill: &Learned, + stats: Vec<(BuffStat, i32)>, + now: Instant, +) -> Vec<(BuffStat, i16)> { + let duration = Duration::from_secs(skill.effect.duration.max(0) as u64); + let stats: Vec<_> = stats + .into_iter() + .map(|(stat, value)| (stat, value as i16)) + .collect(); + for &(stat, value) in &stats { + player + .buffs + .give(Buff::new(skill.skill_id, stat, value, now, duration)); + } + stats +} + +/// The GIVE_FOREIGN_BUFF showing the others the buffs among `stats` they +/// see, if any. +fn show_given(player: &Player, stats: &[(BuffStat, i16)]) -> Result, NetworkError> { + let given: Vec<_> = player + .buffs + .shown() + .into_iter() + .filter(|(stat, _)| stats.iter().any(|(given, _)| given == stat)) + .collect(); + if given.is_empty() { + return Ok(None); + } + Ok(Some(buff::build_give_foreign_buff(player.id(), &given)?)) +} + +/// A party member's skill reached the player, who stands at `at`; it was +/// used at `origin`. If they are alive and in its area, give them its +/// buffs, or heal them, and show them and the others its `affected` +/// animation on them. +pub fn party_skill_reached( + player: &mut Player, + used: &PartySkill, + origin: (i16, i16), + at: (i16, i16), + now: Instant, +) -> WorldResult { + let Some(skill) = skills::at_level(used.skill_id, used.level) else { + return Ok(HandlerResult::empty()); + }; + if player.stats.hp <= 0 || !skills::in_area(&skill.effect, origin, at) { + return Ok(HandlerResult::empty()); + } + let mut result = if used.skill_id == skills::HEAL { + let hp = player.add_hp(used.heal.max(0)); + HandlerResult::reply(stat::build_stat_update(false, &[StatUpdate::Hp(hp)])?) + } else { + let Some(stats) = skills::buffs(&skill) else { + return Ok(HandlerResult::empty()); + }; + let stats = give(player, &skill, stats, now); + let mut result = HandlerResult::reply(buff::build_give_buff( + skill.skill_id, + skill.effect.duration * 1000, + &stats, + )?); + if let Some(foreign) = show_given(player, &stats)? { + result = result.with(WorldAction::BuffsChanged(foreign)); + } + result + }; + let level = skill.level as u8; + result = result.with_reply(buff::build_own_affected(skill.skill_id, level)?); + let shown = buff::build_foreign_affected(player.id(), skill.skill_id, level)?; + Ok(result.with(WorldAction::Field(FieldRequest::Broadcast(shown)))) +} + /// The new MP, the items spent, the buff, and the cooldown for the player; /// the skill's effect and the buffs they show for everyone else on the map. fn announce(player: &Player, used: &Used) -> WorldResult { @@ -347,14 +465,7 @@ fn announce(player: &Player, used: &Used) -> WorldResult { skill.level as u8, )?; result = result.with(WorldAction::Field(FieldRequest::Broadcast(shown))); - let given: Vec<_> = player - .buffs - .shown() - .into_iter() - .filter(|(stat, _)| stats.iter().any(|(given, _)| given == stat)) - .collect(); - if !given.is_empty() { - let foreign = buff::build_give_foreign_buff(player.id(), &given)?; + if let Some(foreign) = show_given(player, stats)? { result = result.with(WorldAction::BuffsChanged(foreign)); } Ok(result) @@ -585,10 +696,10 @@ mod tests { // Alone, the lowest roll: (0.3 + 1) * 300% * (80 * 1.5 + 100) * 400 // * 0.005. let mut player = cleric(); - assert_eq!(heal(&mut player, 30, 0x80, 0.0), Ok(50 + 1716)); + assert_eq!(heal(&mut player, 30, 0x80, 0.0), Ok((50 + 1716, 1716))); // Reaching two, each gets (0.6 + 1) / 2 of it. let mut player = cleric(); - assert_eq!(heal(&mut player, 30, 0x30, 0.0), Ok(50 + 1056)); + assert_eq!(heal(&mut player, 30, 0x30, 0.0), Ok((50 + 1056, 1056))); // Heal spends the MP, not its attack. let cost = skills::learned(&player, skills::HEAL) .unwrap() @@ -602,6 +713,170 @@ mod tests { assert_eq!(heal(&mut player, 30, 0x80, 0.0), Err(Refusal::NotEnoughMp)); } + /// USE_SKILL for a skill reaching the party in `slots`, as the client + /// sends it. + fn handle_party(player: &mut Player, skill_id: i32, level: u8, slots: u8) -> Vec { + let mut packet = Packet::new_empty(); + packet.write_short(RecvOpcode::UseSkill as i16).unwrap(); + packet.write_int(0).unwrap(); + packet.write_int(skill_id).unwrap(); + packet.write_byte(level).unwrap(); + if skills::sends_position(skill_id) { + packet.write_short(10).unwrap(); + packet.write_short(20).unwrap(); + } + packet.write_byte(slots).unwrap(); + packet.write_short(0).unwrap(); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player, + }; + UseSkillHandler + .handle(&mut packet, &mut ctx) + .unwrap() + .actions + } + + fn passed_on(actions: &[WorldAction]) -> Vec { + actions + .iter() + .filter_map(|action| match action { + WorldAction::PartySkill(used) => Some(used.clone()), + _ => None, + }) + .collect() + } + + fn bishop_with(skill_id: i32, level: i32) -> Player { + let mut player = beginner_with(skill_id, level); + player.stats.int = 400; + player.stats.luk = 100; + player.stats.max_hp = 10_000; + (player.stats.mp, player.stats.max_mp) = (1000, 1000); + player + } + + #[test] + fn party_skills_are_passed_on_to_the_members_the_client_found() { + if skills::data().is_err() { + return; + } + // Bless, the user in slot 0 and another in slot 2. + let mut player = bishop_with(2301004, 20); + let actions = handle_party(&mut player, 2301004, 20, 0x28); + assert!(actions.iter().any(unlocks)); + assert_eq!( + passed_on(&actions), + [PartySkill { + skill_id: 2301004, + level: 20, + slots: 0x28, + heal: 0 + }] + ); + // Alone, or with only themselves around. + let mut player = bishop_with(2301004, 20); + assert!(passed_on(&handle_party(&mut player, 2301004, 20, skills::ALONE)).is_empty()); + let mut player = bishop_with(2301004, 20); + assert!(passed_on(&handle_party(&mut player, 2301004, 20, 0)).is_empty()); + // A packet cut short after the level. + let mut player = bishop_with(2301004, 20); + assert!(passed_on(&handle(&mut player, 2301004, 20)).is_empty()); + + // Heal passes on one's share. + let mut player = bishop_with(skills::HEAL, 30); + let used = passed_on(&handle_party(&mut player, skills::HEAL, 30, 0x30)); + assert_eq!(used.len(), 1); + assert_eq!(used[0].slots, 0x30); + assert_eq!(player.stats.hp as i32, 50 + used[0].heal); + } + + #[test] + fn a_party_skill_reaches_members_alive_in_its_area() { + if skills::data().is_err() { + return; + } + let now = Instant::now(); + let bless = PartySkill { + skill_id: 2301004, + level: 20, + slots: 0x30, + heal: 0, + }; + let mut player = beginner_with(1000, 1); + player.stats.max_hp = 10_000; + let sent = opcodes(party_skill_reached( + &mut player, + &bless, + (0, 0), + (350, -250), + now, + )); + assert_eq!( + sent, + [ + SendOpcode::GiveBuff as i16, + SendOpcode::ShowItemGainInChat as i16, + SendOpcode::ShowForeignEffect as i16, + ] + ); + assert!(player + .buffs + .active() + .iter() + .all(|buff| buff.skill_id == 2301004 && buff.expires > now)); + assert!(!player.buffs.active().is_empty()); + + // Past its area and the slack, or dead. + let mut player = beginner_with(1000, 1); + assert!(opcodes(party_skill_reached( + &mut player, + &bless, + (0, 0), + (401, 0), + now + )) + .is_empty()); + assert!(opcodes(party_skill_reached( + &mut player, + &bless, + (0, 0), + (0, 301), + now + )) + .is_empty()); + player.stats.hp = 0; + assert!(opcodes(party_skill_reached( + &mut player, + &bless, + (0, 0), + (0, 0), + now + )) + .is_empty()); + assert!(player.buffs.active().is_empty()); + + let heal = PartySkill { + skill_id: skills::HEAL, + level: 30, + slots: 0x30, + heal: 500, + }; + let mut player = beginner_with(1000, 1); + player.stats.max_hp = 10_000; + let sent = opcodes(party_skill_reached(&mut player, &heal, (0, 0), (0, 0), now)); + assert_eq!( + sent, + [ + SendOpcode::StatChange as i16, + SendOpcode::ShowItemGainInChat as i16, + SendOpcode::ShowForeignEffect as i16, + ] + ); + assert_eq!(player.stats.hp, 550); + } + #[test] fn using_or_refusing_a_skill_unlocks_the_client() { let mut player = beginner_with(1000, 1); diff --git a/net/src/skills.rs b/net/src/skills.rs index e2aba149f..0c75490a0 100644 --- a/net/src/skills.rs +++ b/net/src/skills.rs @@ -109,6 +109,164 @@ pub fn amplification(player: &Player) -> i32 { .map_or(100, |skill| skill.effect.y) } +/// The skill at `level`, whoever has it: what a party member gets from +/// another's skill. +pub fn at_level(skill_id: i32, level: i32) -> Option { + let effect = info(skill_id)?.level(level)?.clone(); + Some(Learned { + skill_id, + level, + effect, + }) +} + +pub const SHADOW_STARS: i32 = 4121006; +pub const DISPEL: i32 = 2311001; +pub const RESURRECTION: i32 = 2321006; + +/// Whether USE_SKILL carries where the player stands (0x96d6ca): the skills +/// that place something, or reach the party around them. +pub fn sends_position(skill_id: i32) -> bool { + matches!( + skill_id, + 1001003 + | 1101006 + | 1111007 + | 1121000 + | 1201006 + | 1211009 + | 1221000 + | 1301006 + | 1301007 + | 1311007 + | 1321000 + | 2101001 + | 2101003 + | 2121000 + | 2201001 + | 2201003 + | 2221000 + | 2301004 + | 2311001 + | 2311003 + | 2321000 + | 2321005 + | 3121000 + | 3121002 + | 3221000 + | 4101004 + | 4111001 + | 4121000 + | 4201003 + | 4221000 + | 5121000 + | 5121009 + | 5221000 + | 11001001 + | 11101003 + | 12101000 + | 12101001 + | 14101003 + | 15111005 + | 21121000 + | 22171000 + | 22181000 + ) +} + +/// Whether a skill reaches the party members around the player (0x966f7a), +/// so that USE_SKILL carries a bit for each one it reached. +pub fn reaches_party(skill_id: i32) -> bool { + matches!( + skill_id, + 1101006 + | 1121000 + | 1221000 + | 1301006 + | 1301007 + | 1321000 + | 2101001 + | 2121000 + | 2201001 + | 2221000 + | 2301004 + | 2311003 + | 2321000 + | 2321005 + | RESURRECTION + | 3121000 + | 3121002 + | 3221000 + | 3221002 + | 4101004 + | 4111001 + | 4121000 + | 4201003 + | 4221000 + | 5121000 + | 5121009 + | 5121010 + | 5221000 + | 11101003 + | 12101000 + | 14101003 + | 15111005 + | 21120007 + | 21121000 + | DISPEL + ) +} + +/// Whether a skill reaches the mobs around the player, so that USE_SKILL +/// carries the ones it reached. +pub fn reaches_mobs(skill_id: i32) -> bool { + matches!( + skill_id, + 1111007 + | 1201006 + | 1211009 + | 1311007 + | 2101003 + | 2111004 + | 2201003 + | 2211004 + | 2311005 + | 4111003 + | 4121004 + | 4221004 + | 12101001 + | 12111002 + | 14111001 + | DISPEL + ) +} + +/// The party a skill reached without one: the player alone. +pub const ALONE: u8 = 0x80; + +/// The bit USE_SKILL sets for the party slot `slot`, the first highest +/// (0x96db3f). +pub fn party_bit(slot: usize) -> u8 { + if slot < 6 { + 1 << (5 - slot) + } else { + 0 + } +} + +/// How far past a skill's area a party member may stand and still be +/// reached: the user's client sees them where they were a moment ago. +pub const AREA_SLACK: i32 = 100; + +/// Whether someone at `at` stands in the area of a skill used at `origin`, +/// whichever way the user faced, give or take `AREA_SLACK`. +pub fn in_area(effect: &SkillLevel, origin: (i16, i16), at: (i16, i16)) -> bool { + let reach_x = effect.lt.0.abs().max(effect.rb.0.abs()) + AREA_SLACK; + let dx = i32::from(at.0) - i32::from(origin.0); + let dy = i32::from(at.1) - i32::from(origin.1); + dx.abs() <= reach_x && effect.lt.1 - AREA_SLACK <= dy && dy <= effect.rb.1 + AREA_SLACK +} + /// Heal: it heals the party around the cleric and hurts the undead. pub const HEAL: i32 = 2301002; diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index b5672d88c..c16c80f5e 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -357,6 +357,15 @@ impl ClientActor { }) .await? } + WorldAction::PartySkill(used) => { + let origin = self.in_game()?.position; + self.send_to_directory(DirectoryMessage::PartySkill { + from: self.client_id, + used, + origin, + }) + .await? + } WorldAction::Buddy(request) => self.buddy_request(request).await?, WorldAction::Find { target_name } => { self.send_to_directory(DirectoryMessage::Find { @@ -909,6 +918,11 @@ impl ClientActor { }) .await? } + ServerMessage::PartySkill { + used, + origin, + map_id, + } => self.party_skill_reached(used, origin, map_id).await?, ServerMessage::ReactorBroken { reactor_id, object_id, diff --git a/runtime/src/actor/client/buff.rs b/runtime/src/actor/client/buff.rs index 420593efc..24b61a16f 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -4,9 +4,43 @@ use crate::message::FieldMessage; use game::BuffStat; use net::packet::build::world::buff; use net::packet::build::world::stat::{self, StatUpdate}; +use net::packet::handle::world::party_skill_reached; +use net::PartySkill; use packet::Packet; impl ClientActor { + /// A party member's skill reached the player: if they are still on its + /// map, out of the Cash Shop, take its buffs or HP. + pub(super) async fn party_skill_reached( + &mut self, + used: PartySkill, + origin: (i16, i16), + map_id: i32, + ) -> Result<(), RuntimeError> { + if self.in_cash_shop { + return Ok(()); + } + let Some(game) = self.game.as_mut() else { + return Ok(()); + }; + if game.player.stats.map_id != map_id { + return Ok(()); + } + let at = game.position; + let result = party_skill_reached( + &mut game.player, + &used, + origin, + at, + std::time::Instant::now(), + ) + .map_err(|e| RuntimeError::Handler(e.to_string()))?; + for action in result.actions { + self.apply(action).await?; + } + Ok(()) + } + /// When the player's buffs next need `update_buffs`. pub(super) fn buffs_due(&self) -> Option { let deadline = self.game.as_ref()?.player.buffs.next_deadline()?; diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index 540195c5a..8f6fb91b9 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -8,7 +8,7 @@ use net::packet::build::world::party::{ build_party_joined, build_party_leader_changed, build_party_left, build_party_member_hp, build_party_status, build_party_update, Departure, PartyMemberView, PartyStatus, PartyView, }; -use net::{BroadcastScope, ClientId, MessengerRequest, PartyRequest, WhisperDelivery}; +use net::{BroadcastScope, ClientId, MessengerRequest, PartyRequest, PartySkill, WhisperDelivery}; use packet::Packet; use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -104,6 +104,9 @@ impl WorldDirectory { DirectoryMessage::Party { from, request } => { self.party_request(from, request, Instant::now()) } + DirectoryMessage::PartySkill { from, used, origin } => { + self.party_skill(from, used, origin) + } DirectoryMessage::BuddyList { owner, buddies } => self.send_buddy_list(owner, buddies), DirectoryMessage::Deliver { to, packet } => { for client in to { @@ -490,6 +493,29 @@ impl WorldDirectory { } } + /// Pass a party skill on to the members in its slots, the party's + /// joining order, who are on the user's map. + fn party_skill(&self, from: ClientId, used: PartySkill, origin: (i16, i16)) { + let (Some(entry), Some(party)) = (self.clients.get(&from), self.parties.of(from)) else { + return; + }; + for (slot, member) in party.members.iter().enumerate() { + if member.id == from || used.slots & net::skills::party_bit(slot) == 0 { + continue; + } + let Some(other) = self.clients.get(&member.id) else { + continue; + }; + if other.shares_map_with(entry) { + other.handle.send(ServerMessage::PartySkill { + used: used.clone(), + origin, + map_id: entry.presence.map_id, + }); + } + } + } + /// Online party members on the same map as `client`. fn party_neighbours<'a>( &'a self, @@ -597,6 +623,53 @@ mod tests { seen } + #[test] + fn a_party_skill_reaches_the_members_in_its_slots_on_the_map() { + let (_tx, rx) = mpsc::channel(1); + let mut directory = WorldDirectory::new(rx); + let mut alice = online(&mut directory, 1, "Alice", 10000); + let mut bobby = online(&mut directory, 2, "Bobby", 10000); + let mut carol = online(&mut directory, 3, "Carol", 20000); + let mut dave = online(&mut directory, 4, "Dave", 10000); + let now = Instant::now(); + for (id, name) in [(2, "Bobby"), (3, "Carol"), (4, "Dave")] { + let invite = PartyRequest::Invite { name: name.into() }; + directory.party_request(1, invite, now); + directory.party_request(id, PartyRequest::Accept { party_id: 1 }, now); + } + for rx in [&mut alice, &mut bobby, &mut carol, &mut dave] { + received(rx); + } + + // Bobby, in slot 1, reached Alice (0), himself (1), and Carol (2) on + // another map, but not Dave (3). + let used = PartySkill { + skill_id: 2301004, + level: 20, + slots: 0x38, + heal: 0, + }; + directory.party_skill(2, used.clone(), (5, 6)); + let reached = |rx: &mut mpsc::Receiver| { + let mut seen = Vec::new(); + while let Ok(message) = rx.try_recv() { + if let ServerMessage::PartySkill { + used, + origin, + map_id, + } = message + { + seen.push((used, origin, map_id)); + } + } + seen + }; + assert_eq!(reached(&mut alice), [(used, (5, 6), 10000)]); + assert!(reached(&mut bobby).is_empty()); + assert!(reached(&mut carol).is_empty()); + assert!(reached(&mut dave).is_empty()); + } + #[test] fn invite_join_hp_chat_and_disband() { let (_tx, rx) = mpsc::channel(1); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index c7fba27c4..4f5d8da14 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -14,7 +14,8 @@ use game::{AvatarLook, FieldAvatar, Player}; use net::{ - BroadcastScope, ClientId, FieldRequest, MessengerRequest, PartyRequest, WhisperDelivery, + BroadcastScope, ClientId, FieldRequest, MessengerRequest, PartyRequest, PartySkill, + WhisperDelivery, }; use packet::Packet; use tokio::sync::mpsc; @@ -67,6 +68,13 @@ pub enum ServerMessage { PartyChanged { party_id: Option, }, + /// A party member on `map_id` used a skill that reached this player, + /// standing at `origin`. + PartySkill { + used: PartySkill, + origin: (i16, i16), + map_id: i32, + }, /// This player broke a reactor; its loot drops for them. ReactorBroken { reactor_id: i32, @@ -272,6 +280,12 @@ pub enum DirectoryMessage { from: ClientId, request: PartyRequest, }, + /// `from` used a skill at `origin` on the party members in its slots. + PartySkill { + from: ClientId, + used: PartySkill, + origin: (i16, i16), + }, Unregister { client_id: ClientId, connection_id: u64,