From 990fc626adad82d1df3efa4a2bf0e4afbf289bb6 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 00:15:27 -0400 Subject: [PATCH 1/5] Wait for the server's ack between a controlled mob's moves as MapleRoyals does Handle MOVE_MOB_RESPONSE (FUN_0066c23b) with v83's control state, number each move, and send the latest move once the last is acked. MOVE_MONSTER now carries the action byte and sentinels v83 writes. --- HeavenClient/Gameplay/MapleMap/MapMobs.cpp | 6 +++ HeavenClient/Gameplay/MapleMap/MapMobs.h | 3 ++ HeavenClient/Gameplay/MapleMap/Mob.cpp | 40 ++++++++++++++++++- HeavenClient/Gameplay/MapleMap/Mob.h | 15 ++++++- .../Net/Handlers/MapObjectHandlers.cpp | 14 +++++++ HeavenClient/Net/Handlers/MapObjectHandlers.h | 7 ++++ HeavenClient/Net/PacketSwitch.cpp | 2 + HeavenClient/Net/Packets/GameplayPackets.h | 26 +++++++----- 8 files changed, 100 insertions(+), 13 deletions(-) diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp index 4520a48f6..dcff6896c 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp @@ -84,6 +84,12 @@ namespace ms mob->set_control(mode); } + void MapMobs::acknowledge(int32_t oid, int16_t moveid, bool nextattack) + { + if (Optional mob = mobs.get(oid)) + mob->acknowledge(moveid, nextattack); + } + void MapMobs::send_mobhp(int32_t oid, int8_t percent, uint16_t playerlevel) { if (Optional mob = mobs.get(oid)) diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.h b/HeavenClient/Gameplay/MapleMap/MapMobs.h index 94f1d3a28..18f368639 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.h +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.h @@ -55,6 +55,9 @@ namespace ms // Update a mob's hp display. void send_mobhp(int32_t oid, int8_t percent, uint16_t playerlevel); + // The server got a mob's move. + void acknowledge(int32_t oid, int16_t moveid, bool nextattack); + // Update a mob's movements. void send_movement(int32_t oid, Point start, std::vector &&movements); diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index d749718ad..d0a36f8b9 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -752,10 +752,41 @@ namespace ms void Mob::update_movement() { + // The controller sends nothing new until its last move is acked, + // then its latest (0x6685ac). + if (control && ctrlstate == 2) + { + movepending = true; + return; + } + + movepending = false; + + // It asks to attack when chasing, unless controlling, then 70% of + // the time. + bool wantattack = hastarget && (ctrlstate < 0 || randomizer.next_int(100) < 70); + int8_t flags = (ctrlstate < 0 ? 0x10 : 0) | (wantattack ? 1 : 0); + MoveMobPacket( - oid, 1, 0, 0, 0, 0, 0, 0, get_position(), + oid, ++moveid, flags, -1, false, 0, canfly, get_position(), Movement(phobj, value_of(stance, flip)) ).dispatch(); + + ctrlstate = ctrlstate == -2 || ctrlstate == 1 ? 1 : 2; + } + + void Mob::acknowledge(int16_t ackid, bool nextattack) + { + if (!control) + set_control(1); + + if (ackid < moveid) + ctrlstate = 2; + else + ctrlstate = nextattack ? 4 : 3; + + if (ctrlstate != 2 && movepending) + update_movement(); } void Mob::draw(double viewx, double viewy, float alpha) const @@ -819,6 +850,13 @@ namespace ms void Mob::set_control(int8_t mode) { + // Taking control lets it move at once; it may attack only once a + // move is acked. Letting go remembers whether it could have. + if (mode > 0 && !control) + ctrlstate = 3; + else if (mode == 0 && control) + ctrlstate = ctrlstate == 3 ? -2 : ctrlstate == 4 ? -3 : -1; + control = mode > 0; // v83 (FUN_005d511d) has a mob given with aggro chase the local diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 0173b9dee..5c3fe7d65 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -92,6 +92,10 @@ namespace ms // Tell the mob where the local player is, whom it chases with aggro. void set_target(Point position, bool dead); + // The server got the move with this id (FUN_0066c23b), saying whether + // the mob may attack or cast next. + void acknowledge(int16_t moveid, bool nextattack); + // Send movement to the mob. void send_movement(Point start, std::vector &&movements); @@ -276,7 +280,16 @@ namespace ms int8_t team; bool dying; bool dead; - bool control; + bool control = false; + // v83's control state (CMob +0x130): 3 when the controller may send + // a move, 4 when it may also attack or cast, 2 while a move awaits + // the server's ack, 1 after a move sent without control, and below 0 + // when not controlling (FUN_006637ec). + int8_t ctrlstate = -1; + // The id of the last move sent, one more each move. + int16_t moveid = 0; + // Whether a move came up while awaiting an ack, to be sent with it. + bool movepending = false; // Whether the mob chases the local player, and for how many more ms. bool hastarget; int32_t targettime; diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 8eba8ab6e..49cb6d8b3 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -346,6 +346,20 @@ namespace ms Stage::get().get_mobs().send_movement(oid, position, std::move(movements)); } + void MoveMobResponseHandler::handle(InPacket& recv) const + { + int32_t oid = recv.read_int(); + int16_t moveid = recv.read_short(); + bool nextattack = recv.read_bool(); + + // Its MP and a skill to cast next: mob skills aren't modeled yet. + recv.read_short(); + recv.read_byte(); + recv.read_byte(); + + Stage::get().get_mobs().acknowledge(oid, moveid, nextattack); + } + void ShowMobHpHandler::handle(InPacket& recv) const { int32_t oid = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 25e993da6..1a9f0c65d 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -106,6 +106,13 @@ namespace ms void handle(InPacket& recv) const override; }; + // The server got the controller's move of a mob (FUN_0066c23b). + // Opcode: MOVE_MOB_RESPONSE(240) + class MoveMobResponseHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Update mob state and position with the client // Opcode: MOB_MOVED(239) class MobMovedHandler : public PacketHandler diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index c25738183..40908bbc6 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -127,6 +127,7 @@ namespace ms KILL_MOB = 237, SPAWN_MOB_C = 238, MOB_MOVED = 239, + MOVE_MOB_RESPONSE = 240, SHOW_MOB_HP = 250, SHOW_MAGNET = 251, SPAWN_NPC = 257, @@ -192,6 +193,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index ee69aff97..80a562686 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -413,19 +413,23 @@ namespace ms class MoveMobPacket : public MovementPacket { public: - // Updates a mob's position with the server - MoveMobPacket(int32_t oid, int16_t type, int8_t skillb, int8_t skill0, int8_t skill1, int8_t skill2, int8_t skill3, int8_t skill4, Point startpos, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) + // Updates a mob's position with the server, as v83 does + // (FUN_0066b6fc): its move id, flags, its action (-1 for none) and + // facing, skill data, an anti-cheat byte and reason, and two ints a + // flyer fills in but others leave at 0xFFDDCC. + MoveMobPacket(int32_t oid, int16_t moveid, int8_t flags, int8_t action, bool left, int32_t skilldata, bool flying, Point startpos, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) { write_int(oid); - write_short(type); - write_byte(skillb); - write_byte(skill0); - write_byte(skill1); - write_byte(skill2); - write_byte(skill3); - write_byte(skill4); - - skip(13); + write_short(moveid); + write_byte(flags); + write_byte(action < 0 ? -1 : (action << 1) | left); + write_int(skilldata); + write_byte(0); + write_int(0); + + int32_t unknown = flying ? 0 : 0x00FFDDCC; + write_int(unknown); + write_int(unknown); write_point(startpos); From 86d33d336d230ad36e500f0c81c9e9e15299f09c Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 00:21:20 -0400 Subject: [PATCH 2/5] Place skill hit effects by their pos as MapleRoyals does Effects without a pos land where the hit met the body and stay there; pos 1 sits at the feet, 2 on the current frame's head, 3 in the middle of the body and 4 where the hit landed, following the mob. A body-less frame shows no effect at the body. Hit sparks use the faced body. --- HeavenClient/CMakeLists.txt | 3 + HeavenClient/Gameplay/Combat/HitPoint.h | 103 ++++++++++++++++++ .../Gameplay/Combat/SkillHitEffect.cpp | 10 +- HeavenClient/Gameplay/Combat/SkillHitEffect.h | 9 +- HeavenClient/Gameplay/MapleMap/Mob.cpp | 65 +++++------ HeavenClient/Gameplay/MapleMap/Mob.h | 10 +- HeavenClient/tests/hit_point.cpp | 65 +++++++++++ 7 files changed, 218 insertions(+), 47 deletions(-) create mode 100644 HeavenClient/Gameplay/Combat/HitPoint.h create mode 100644 HeavenClient/tests/hit_point.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index e24f9437e..c443c2901 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -106,6 +106,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(remote_spell_tests tests/remote_spell.cpp) target_include_directories(remote_spell_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME remote_spell COMMAND remote_spell_tests) + add_executable(hit_point_tests tests/hit_point.cpp) + target_include_directories(hit_point_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME hit_point COMMAND hit_point_tests) add_executable(remote_shot_tests tests/remote_shot.cpp) target_include_directories(remote_shot_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME remote_shot COMMAND remote_shot_tests) diff --git a/HeavenClient/Gameplay/Combat/HitPoint.h b/HeavenClient/Gameplay/Combat/HitPoint.h new file mode 100644 index 000000000..5263b4cce --- /dev/null +++ b/HeavenClient/Gameplay/Combat/HitPoint.h @@ -0,0 +1,103 @@ +#pragma once + +#include "../../Template/Rectangle.h" + +#include +#include +#include + +namespace ms +{ + // Where a hit lands on a mob, and where a skill's hit effect is drawn, + // as in the MapleRoyals client. Rectangles and points are in map + // coordinates. + namespace hit_point + { + // The middle of the overlap of the attack and the body + // (FUN_00664603), first stretching an attack that falls short to + // touch the body by a pixel. + inline Point centre(const Rectangle &range, const Rectangle &body) + { + int16_t left = range.left(); + int16_t right = range.right(); + int16_t top = range.top(); + int16_t bottom = range.bottom(); + + if (left >= body.right()) + left = body.right() - 1; + else if (right <= body.left()) + right = body.left() + 1; + + if (top >= body.bottom()) + top = body.bottom() - 1; + else if (bottom <= body.top()) + bottom = body.top() + 1; + + return { + static_cast((std::max(left, body.left()) + std::min(right, body.right())) / 2), + static_cast((std::max(top, body.top()) + std::min(bottom, body.bottom())) / 2) + }; + } + + // What a hit effect's `pos` attaches it to (FUN_0066936e). + enum class Anchor + { + // The mob's feet, following it. + FEET, + // The current frame's head, following the mob and its frames. + HEAD, + // A point in the field that stays put if the mob moves. + FIELD + }; + + struct Placement + { + Anchor anchor; + // From the feet for FEET, from the head for HEAD, and the point + // itself for FIELD. + Point offset; + }; + + // An effect needs a body to land on, except at the feet or head. + inline bool hollow(const Rectangle &body) + { + return body.right() <= body.left() || body.bottom() <= body.top(); + } + + inline std::optional place(int8_t pos, const Rectangle &body, Point mob, + Point hit) + { + switch (pos) + { + case 1: + return Placement{ Anchor::FEET, {}}; + case 2: + return Placement{ Anchor::HEAD, {}}; + case 3: + if (hollow(body)) + return std::nullopt; + + return Placement{ + Anchor::FEET, + Point( + static_cast((body.left() + body.right()) / 2 - mob.x()), + static_cast((body.top() + body.bottom()) / 2 - mob.y()) + ) + }; + case 4: + return Placement{ Anchor::FEET, hit - mob }; + default: + if (hollow(body)) + return std::nullopt; + + return Placement{ + Anchor::FIELD, + Point( + std::clamp(hit.x(), body.left(), body.right()), + std::clamp(hit.y(), body.top(), body.bottom()) + ) + }; + } + } + } +} diff --git a/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp b/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp index 84fb44d11..d89383f2c 100644 --- a/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp +++ b/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp @@ -26,14 +26,14 @@ namespace ms void SingleHitEffect::apply(const AttackUser &user, Mob &target) const { - effect.apply(target, user.flip); + effect.apply(target, user); } TwoHandedHitEffect::TwoHandedHitEffect(nl::node src) : effects(src["hit"]["0"], src["hit"]["1"]) {} void TwoHandedHitEffect::apply(const AttackUser& user, Mob& target) const { - effects[user.secondweapon].apply(target, user.flip); + effects[user.secondweapon].apply(target, user); } ByLevelHitEffect::ByLevelHitEffect(nl::node src) @@ -57,7 +57,7 @@ namespace ms if (iter != effects.begin()) iter--; - iter->second.apply(target, user.flip); + iter->second.apply(target, user); } ByLevelTwoHandedHitEffect::ByLevelTwoHandedHitEffect(nl::node src) @@ -85,7 +85,7 @@ namespace ms if (iter != effects.begin()) iter--; - iter->second[user.secondweapon].apply(target, user.flip); + iter->second[user.secondweapon].apply(target, user); } BySkillLevelHitEffect::BySkillLevelHitEffect(nl::node src) @@ -102,6 +102,6 @@ namespace ms auto iter = effects.find(user.skilllevel); if (iter != effects.end()) - iter->second.apply(target, user.flip); + iter->second.apply(target, user); } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Combat/SkillHitEffect.h b/HeavenClient/Gameplay/Combat/SkillHitEffect.h index 433099c91..f949361ce 100644 --- a/HeavenClient/Gameplay/Combat/SkillHitEffect.h +++ b/HeavenClient/Gameplay/Combat/SkillHitEffect.h @@ -41,12 +41,15 @@ namespace ms { animation = src; pos = src["pos"]; - z = src["z"]; + // As in the MapleRoyals client, above the mob unless the + // node says otherwise (0x669773). + z = src["z"].data_type() == nl::node::type::integer ? static_cast(src["z"]) : 1; } - void apply(Mob &target, bool flip) const + // Flipped when the attacker faces right. + void apply(Mob &target, const AttackUser &user) const { - target.show_effect(animation, pos, z, flip); + target.show_effect(animation, pos, z, user.flip, user.range); } private: diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index d0a36f8b9..15e247ed6 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -18,6 +18,7 @@ #include "Mob.h" #include "Layer.h" +#include "../Combat/HitPoint.h" #include "../Combat/MonsterMagnet.h" #include "../Combat/Prepare.h" #include "../../Character/TouchDamage.h" @@ -257,6 +258,8 @@ namespace ms } effects.update(); + headeffects.update(); + fieldeffects.update(); showhp.update(); // Every client, controller or not, tells the server once a second how @@ -793,7 +796,12 @@ namespace ms { Point absp = phobj.get_absolute(viewx, viewy, alpha); + Point headp = get_head_position(absp); + Point viewp = Point(static_cast(viewx), static_cast(viewy)); + effects.drawbelow(absp, alpha); + headeffects.drawbelow(headp, alpha); + fieldeffects.drawbelow(viewp, alpha); if (!dead) { @@ -829,6 +837,8 @@ namespace ms } effects.drawabove(absp, alpha); + headeffects.drawabove(headp, alpha); + fieldeffects.drawabove(viewp, alpha); } void Mob::draw_hp_bar(Point position) const @@ -1014,29 +1024,33 @@ namespace ms showhp.set_for(5000); } - void Mob::show_effect(const Animation &animation, int8_t pos, int8_t z, bool f) + void Mob::show_effect(const Animation &animation, int8_t pos, int8_t z, bool f, const Rectangle &range) { if (!active) return; - Point shift; + Rectangle body = get_body(); + Point position = get_position(); + std::optional placement = + hit_point::place(pos, body, position, hit_point::centre(range, body)); + + if (!placement) + return; + + DrawArgument args(placement->offset, f); - switch (pos) + switch (placement->anchor) { - case 0: - shift = get_head_position(Point()); - break; - case 1: - break; - case 2: + case hit_point::Anchor::FEET: + effects.add(animation, args, z); break; - case 3: + case hit_point::Anchor::HEAD: + headeffects.add(animation, args, z); break; - case 4: + case hit_point::Anchor::FIELD: + fieldeffects.add(animation, args, z); break; } - - effects.add(animation, DrawArgument(shift, f), z); } void Mob::show_hit(const Animation &animation, const Rectangle &range, bool f) @@ -1044,30 +1058,7 @@ namespace ms if (!active) return; - Rectangle body = animations.at(stance).get_bounds(); - body.shift(get_position()); - - // As v83 does, centre the spark on the overlap of the attack and the - // body, first stretching an attack that falls short to touch it. - int16_t left = range.left(); - int16_t right = range.right(); - int16_t top = range.top(); - int16_t bottom = range.bottom(); - - if (left >= body.right()) - left = body.right() - 1; - else if (right <= body.left()) - right = body.left() + 1; - - if (top >= body.bottom()) - top = body.bottom() - 1; - else if (bottom <= body.top()) - bottom = body.top() + 1; - - int16_t x = (std::max(left, body.left()) + std::min(right, body.right())) / 2; - int16_t y = (std::max(top, body.top()) + std::min(bottom, body.bottom())) / 2; - - effects.add(animation, DrawArgument(Point(x, y) - get_position(), f), 0); + effects.add(animation, DrawArgument(hit_point::centre(range, get_body()) - get_position(), f), 0); } float Mob::calculate_hitchance(int16_t leveldelta, int32_t player_accuracy) const diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 5c3fe7d65..f4457fdd9 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -112,8 +112,10 @@ namespace ms // Use the playerlevel to determine color of nametag. void show_hp(int8_t percentage, uint16_t playerlevel); - // Show an effect at the mob's position. - void show_effect(const Animation &animation, int8_t pos, int8_t z, bool flip); + // Show a skill's hit effect where its `pos` puts it, given where the + // attack reached. + void show_effect(const Animation &animation, int8_t pos, int8_t z, bool flip, + const Rectangle &range); // Show a weapon's hit spark where the attack meets the mob's body. void show_hit(const Animation &animation, const Rectangle &range, bool flip); @@ -258,6 +260,10 @@ namespace ms int16_t standwidth; EffectLayer effects; + // Hit effects on the current frame's head, and those left at a point + // in the field. + EffectLayer headeffects; + EffectLayer fieldeffects; Text namelabel; // "Lv. N", drawn left of the name tag (Basic.img/ShowLevel). std::vector leveltag; diff --git a/HeavenClient/tests/hit_point.cpp b/HeavenClient/tests/hit_point.cpp new file mode 100644 index 000000000..ce29a3a8b --- /dev/null +++ b/HeavenClient/tests/hit_point.cpp @@ -0,0 +1,65 @@ +#include "../Gameplay/Combat/HitPoint.h" + +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms; + using namespace ms::hit_point; + + // Left, right, top, bottom. + Rectangle body{ 80, 120, 140, 200 }; + + require(centre({ 100, 200, 150, 170 }, body) == Point(110, 160), "the middle of the overlap"); + require(centre({ 0, 50, 150, 170 }, body) == Point(80, 160), "a short attack touches the body"); + require(centre({ 130, 180, 150, 170 }, body) == Point(119, 160), "from either side"); + + Point mob{ 100, 200 }; + Point hit{ 110, 160 }; + + auto feet = place(1, body, mob, hit); + require(feet && feet->anchor == Anchor::FEET && feet->offset == Point(), "pos 1 at the feet"); + + auto head = place(2, body, mob, hit); + require(head && head->anchor == Anchor::HEAD && head->offset == Point(), "pos 2 on the head"); + + auto middle = place(3, body, mob, hit); + require(middle && middle->anchor == Anchor::FEET && middle->offset == Point(0, -30), + "pos 3 in the middle of the body"); + + auto struck = place(4, body, mob, hit); + require(struck && struck->anchor == Anchor::FEET && struck->offset == Point(10, -40), + "pos 4 where the hit landed, following the mob"); + + auto field = place(0, body, mob, hit); + require(field && field->anchor == Anchor::FIELD && field->offset == hit, "no pos: where the hit landed"); + + auto clamped = place(0, body, mob, { 300, 100 }); + require(clamped && clamped->offset == Point(120, 140), "kept on the body, edges included"); + + Rectangle hollow{ 100, 100, 150, 200 }; + + require(!place(0, hollow, mob, hit), "no body, no effect"); + require(!place(3, hollow, mob, hit), "nor in its middle"); + require(place(1, hollow, mob, hit) && place(2, hollow, mob, hit) && place(4, hollow, mob, hit), + "but the feet and head still show one"); + + std::cout << "hit point tests passed\n"; + } + catch (const std::exception &error) + { + std::cerr << error.what() << '\n'; + + return 1; + } + + return 0; +} From 2d0e26bfe0eff6f212e93fee301809ba17fedb60 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 00:23:45 -0400 Subject: [PATCH 3/5] Focus the last world played as MapleRoyals does LAST_CONNECTED_WORLD (26) puts the key focus on that world's plank. --- HeavenClient/IO/UITypes/UIWorldSelect.cpp | 12 ++++++++++++ HeavenClient/IO/UITypes/UIWorldSelect.h | 3 +++ HeavenClient/Net/Handlers/LoginHandlers.cpp | 16 ++++++++++++++++ HeavenClient/Net/Handlers/LoginHandlers.h | 8 ++++++++ HeavenClient/Net/PacketSwitch.cpp | 2 ++ 5 files changed, 41 insertions(+) diff --git a/HeavenClient/IO/UITypes/UIWorldSelect.cpp b/HeavenClient/IO/UITypes/UIWorldSelect.cpp index 8d8c24004..935f5e4ee 100644 --- a/HeavenClient/IO/UITypes/UIWorldSelect.cpp +++ b/HeavenClient/IO/UITypes/UIWorldSelect.cpp @@ -420,6 +420,18 @@ namespace ms recommended_worlds.emplace_back(std::move(world)); } + void UIWorldSelect::focus_world(int32_t worldid) + { + for (size_t i = 0; i < worlds.size() && i < PLANK_SLOTS; i++) + { + if (worlds[i].wid == worldid) + { + focused = static_cast(i); + return; + } + } + } + void UIWorldSelect::remove_selected() { deactivate(); diff --git a/HeavenClient/IO/UITypes/UIWorldSelect.h b/HeavenClient/IO/UITypes/UIWorldSelect.h index 277be5415..6936c9900 100644 --- a/HeavenClient/IO/UITypes/UIWorldSelect.h +++ b/HeavenClient/IO/UITypes/UIWorldSelect.h @@ -55,6 +55,9 @@ namespace ms void add_recommended_world(RecommendedWorld world); + // Put the key focus on the world the account last played in. + void focus_world(int32_t worldid); + void remove_selected(); // The v83 art for a world is keyed by the world id itself. diff --git a/HeavenClient/Net/Handlers/LoginHandlers.cpp b/HeavenClient/Net/Handlers/LoginHandlers.cpp index 64a3be7b8..ab514d537 100644 --- a/HeavenClient/Net/Handlers/LoginHandlers.cpp +++ b/HeavenClient/Net/Handlers/LoginHandlers.cpp @@ -253,6 +253,22 @@ namespace ms } } + void LastConnectedWorldHandler::handle(InPacket& recv) const + { + static int32_t last = 254; + + int32_t world = recv.read_int(); + + if (world != 253) + last = world; + + if (last == 254 || last == 255) + return; + + if (auto worldselect = UI::get().get_element()) + worldselect->focus_world(last); + } + void RecommendedWorldsHandler::handle(InPacket& recv) const { if (auto worldselect = UI::get().get_element()) diff --git a/HeavenClient/Net/Handlers/LoginHandlers.h b/HeavenClient/Net/Handlers/LoginHandlers.h index 990cbaed1..56551ec19 100644 --- a/HeavenClient/Net/Handlers/LoginHandlers.h +++ b/HeavenClient/Net/Handlers/LoginHandlers.h @@ -69,6 +69,14 @@ namespace ms void handle(InPacket& recv) const override; }; + // The world the account last played in, as MapleRoyals does (0x5f82f4): + // its plank gets the key focus. 253 keeps the last one; 254 and 255 + // name none. + class LastConnectedWorldHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Handles the packet that contains information on recommended worlds class RecommendedWorldsHandler : public PacketHandler { diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index 40908bbc6..d5f7a43a3 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -52,6 +52,7 @@ namespace ms PING = 17, /// Login 2 + LAST_CONNECTED_WORLD = 26, RECOMMENDED_WORLDS = 27, CHECK_SPW_RESULT = 28, @@ -173,6 +174,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); // SetField handlers From bdb33217232466cbee143da6fc057672d29ab6d4 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 00:35:33 -0400 Subject: [PATCH 4/5] HeavenClient: pick skill actions at random, play USE_SKILL at speed 6 v83 takes one of a skill's actions at random (FUN_007660b6), not by two-handedness, plays a skill used without attacking at speed 6 (0x96d4a6), and takes 150 ms for a body frame with no delay while keeping a 0 (FUN_00414d40). apply_actions now returns the action it played, so the USE_SKILL lock uses the same one. --- HeavenClient/Character/Char.cpp | 12 +++++ HeavenClient/Character/Char.h | 7 +++ HeavenClient/Character/Look/BodyDrawInfo.cpp | 5 +- HeavenClient/Character/Look/BodyDrawInfo.h | 20 +++---- HeavenClient/Character/Look/CharLook.cpp | 3 +- HeavenClient/Gameplay/Combat/Combat.cpp | 4 +- .../Gameplay/Combat/RegularAttack.cpp | 4 +- HeavenClient/Gameplay/Combat/RegularAttack.h | 2 +- HeavenClient/Gameplay/Combat/Skill.cpp | 15 ++---- HeavenClient/Gameplay/Combat/Skill.h | 4 +- HeavenClient/Gameplay/Combat/SkillAction.cpp | 53 ++++++++----------- HeavenClient/Gameplay/Combat/SkillAction.h | 49 ++++++----------- HeavenClient/Gameplay/Combat/SpecialMove.h | 10 ++-- 13 files changed, 80 insertions(+), 108 deletions(-) diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index afb334642..dc4834256 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -224,6 +224,9 @@ namespace ms { // v83 stretches each frame of an action to delay * (speed + 10) / 16, // holding the speed to 2..10 (FUN_00453ad1). + if (fixed_action_speed > 0) + return fixed_action_speed; + return std::clamp(get_integer_attackspeed(), 2, 10); } @@ -386,6 +389,7 @@ namespace ms look.set_action(action); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -394,6 +398,7 @@ namespace ms look.attack(stance); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -402,6 +407,7 @@ namespace ms look.attack(degenerate); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -410,6 +416,7 @@ namespace ms look.attack(degenerate, stance); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -418,6 +425,11 @@ namespace ms look.hold(frame); } + void Char::fix_action_speed(int8_t speed) + { + fixed_action_speed = speed; + } + void Char::end_attack() { look.end_attack(); diff --git a/HeavenClient/Character/Char.h b/HeavenClient/Character/Char.h index 36665f0ea..1da31e20f 100644 --- a/HeavenClient/Character/Char.h +++ b/HeavenClient/Character/Char.h @@ -128,6 +128,11 @@ namespace ms // Hold the attack on this frame until end_attack. void hold_attack(uint8_t frame); + // Play the action just started at this speed rather than the + // character's own, until the next one. v83 plays the action of a + // skill used without attacking at 6 (0x96d4a6). + void fix_action_speed(int8_t speed); + // End the attack now, held or not. void end_attack(); @@ -250,6 +255,8 @@ namespace ms State state = State::STAND; bool attacking; + // The speed fixed for the current action, or 0 for the character's own. + int8_t fixed_action_speed = 0; // The milliseconds of animation time a speed left over from its last // whole step. float stance_carry = 0.0f; diff --git a/HeavenClient/Character/Look/BodyDrawInfo.cpp b/HeavenClient/Character/Look/BodyDrawInfo.cpp index 4fc78ec65..df662cda4 100644 --- a/HeavenClient/Character/Look/BodyDrawInfo.cpp +++ b/HeavenClient/Character/Look/BodyDrawInfo.cpp @@ -51,10 +51,11 @@ namespace ms } else { Stance::Id stance = Stance::by_string(ststr); - int16_t delay = framenode["delay"]; + int16_t delay = framenode["delay"].data_type() == nl::node::type::integer + ? static_cast(framenode["delay"]) : static_cast(150); if (delay <= 0) - delay = 100; + delay = 150; stance_delays[stance][frame] = delay; diff --git a/HeavenClient/Character/Look/BodyDrawInfo.h b/HeavenClient/Character/Look/BodyDrawInfo.h index 4b2b1189b..dec36b636 100644 --- a/HeavenClient/Character/Look/BodyDrawInfo.h +++ b/HeavenClient/Character/Look/BodyDrawInfo.h @@ -43,20 +43,12 @@ namespace ms frame = src["frame"]; move = src["move"]; - int16_t sgndelay = src["delay"]; - - if (sgndelay == 0) - sgndelay = 100; - - if (sgndelay > 0) - { - delay = sgndelay; - attackframe = true; - } else if (sgndelay < 0) - { - delay = -sgndelay; - attackframe = false; - } + // v83 takes 150 for a missing delay, but keeps a 0 (FUN_00414d40). + int16_t sgndelay = src["delay"].data_type() == nl::node::type::integer + ? static_cast(src["delay"]) : static_cast(150); + + delay = sgndelay < 0 ? -sgndelay : sgndelay; + attackframe = sgndelay >= 0; } BodyAction() diff --git a/HeavenClient/Character/Look/CharLook.cpp b/HeavenClient/Character/Look/CharLook.cpp index 5274d5323..f46e3cffe 100644 --- a/HeavenClient/Character/Look/CharLook.cpp +++ b/HeavenClient/Character/Look/CharLook.cpp @@ -294,8 +294,9 @@ namespace ms } } else { + // A frame of no delay passes at once. uint16_t delay = action->get_delay(); - uint16_t delta = delay - stelapsed; + uint16_t delta = delay > stelapsed ? delay - stelapsed : 0; if (timestep >= delta) { diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 4c829c7b0..11d6b3d61 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -312,9 +312,9 @@ namespace ms return false; move.apply_useeffects(player); - move.apply_actions(player, Attack::Type::MAGIC); + std::string action = move.apply_actions(player, Attack::Type::MAGIC); + player.fix_action_speed(6); - std::string action = move.get_action(player); uint16_t lock = action.empty() ? 0 : player.get_action_lock(action); player.request(UseSkillPacket(moveid, level, lock, *reach), ExclRequest::SKILL_WAIT); diff --git a/HeavenClient/Gameplay/Combat/RegularAttack.cpp b/HeavenClient/Gameplay/Combat/RegularAttack.cpp index 641c552e1..4db4f90e9 100644 --- a/HeavenClient/Gameplay/Combat/RegularAttack.cpp +++ b/HeavenClient/Gameplay/Combat/RegularAttack.cpp @@ -26,9 +26,9 @@ namespace ms void RegularAttack::apply_useeffects(Char &) const {} - void RegularAttack::apply_actions(Char &user, Attack::Type type) const + std::string RegularAttack::apply_actions(Char &user, Attack::Type type) const { - action.apply(user, type); + return action.apply(user, type); } void RegularAttack::apply_stats(const Char &user, Attack &attack) const diff --git a/HeavenClient/Gameplay/Combat/RegularAttack.h b/HeavenClient/Gameplay/Combat/RegularAttack.h index 528919ff0..ad5cef0e2 100644 --- a/HeavenClient/Gameplay/Combat/RegularAttack.h +++ b/HeavenClient/Gameplay/Combat/RegularAttack.h @@ -33,7 +33,7 @@ namespace ms public: void apply_useeffects(Char &user) const override; - void apply_actions(Char &user, Attack::Type type) const override; + std::string apply_actions(Char &user, Attack::Type type) const override; void apply_stats(const Char &user, Attack &attack) const override; diff --git a/HeavenClient/Gameplay/Combat/Skill.cpp b/HeavenClient/Gameplay/Combat/Skill.cpp index a41b2e6e0..83a8c7079 100644 --- a/HeavenClient/Gameplay/Combat/Skill.cpp +++ b/HeavenClient/Gameplay/Combat/Skill.cpp @@ -132,7 +132,6 @@ namespace ms } bool hasaction0 = src["action"]["0"].data_type() == nl::node::type::string; - bool hasaction1 = src["action"]["1"].data_type() == nl::node::type::string; // A thrown grenade hits with no action, and flies as no bullet: it // shows its own effect where it lands (0x950921, 0x936528). @@ -141,12 +140,9 @@ namespace ms if (thrown) { action = std::make_unique(); - } else if (hasaction0 && hasaction1) - { - action = std::make_unique(src); } else if (hasaction0) { - action = std::make_unique(src); + action = std::make_unique(src); } else if (data.is_attack()) { bool bylevel = src["level"]["1"]["action"].data_type() == nl::node::type::string; @@ -211,14 +207,9 @@ namespace ms sound->stop_loop(); } - void Skill::apply_actions(Char &user, Attack::Type type) const - { - action->apply(user, type); - } - - std::string Skill::get_action(const Char &user) const + std::string Skill::apply_actions(Char &user, Attack::Type type) const { - return action->get_action(user); + return action->apply(user, type); } void Skill::apply_stats(const Char &user, Attack &attack) const diff --git a/HeavenClient/Gameplay/Combat/Skill.h b/HeavenClient/Gameplay/Combat/Skill.h index afdfd0ffe..263011d25 100644 --- a/HeavenClient/Gameplay/Combat/Skill.h +++ b/HeavenClient/Gameplay/Combat/Skill.h @@ -70,9 +70,7 @@ namespace ms void apply_useeffects(Char &user) const override; - void apply_actions(Char &user, Attack::Type type) const override; - - std::string get_action(const Char &user) const override; + std::string apply_actions(Char &user, Attack::Type type) const override; void apply_stats(const Char &user, Attack &attack) const override; diff --git a/HeavenClient/Gameplay/Combat/SkillAction.cpp b/HeavenClient/Gameplay/Combat/SkillAction.cpp index 0f22bdb9b..6355e2553 100644 --- a/HeavenClient/Gameplay/Combat/SkillAction.cpp +++ b/HeavenClient/Gameplay/Combat/SkillAction.cpp @@ -18,10 +18,11 @@ #include "SkillAction.h" #include "../../Util/Misc.h" +#include "../../Util/Randomizer.h" namespace ms { - void RegularAction::apply(Char &target, Attack::Type atype) const + std::string RegularAction::apply(Char &target, Attack::Type atype) const { Weapon::Type weapontype = target.get_weapontype(); bool degenerate; @@ -40,37 +41,30 @@ namespace ms } target.attack(degenerate); - } - SingleAction::SingleAction(nl::node src) - { - action = std::string(src["action"]["0"]); + return ""; } - void SingleAction::apply(Char &target, Attack::Type) const + RandomAction::RandomAction(nl::node src) { - target.attack(action); - } + for (size_t i = 0; ; i++) + { + nl::node sub = src["action"][std::to_string(i)]; - std::string SingleAction::get_action(const Char &) const - { - return action; - } + if (sub.data_type() != nl::node::type::string) + break; - TwoHandedAction::TwoHandedAction(nl::node src) - { - actions[false] = std::string(src["action"]["0"]); - actions[true] = std::string(src["action"]["1"]); + actions.push_back(std::string(sub)); + } } - void TwoHandedAction::apply(Char& target, Attack::Type) const + std::string RandomAction::apply(Char &target, Attack::Type) const { - target.attack(get_action(target)); - } + Randomizer randomizer; + const std::string &action = actions[randomizer.next_int(actions.size())]; + target.attack(action); - std::string TwoHandedAction::get_action(const Char &target) const - { - return actions[target.is_twohanded()]; + return action; } ByLevelAction::ByLevelAction(nl::node src, int32_t id) @@ -84,18 +78,15 @@ namespace ms skillid = id; } - void ByLevelAction::apply(Char &target, Attack::Type) const + std::string ByLevelAction::apply(Char &target, Attack::Type) const { - std::string action = get_action(target); + auto iter = actions.find(target.get_skilllevel(skillid)); - if (!action.empty()) - target.attack(action); - } + if (iter == actions.end()) + return ""; - std::string ByLevelAction::get_action(const Char &target) const - { - auto iter = actions.find(target.get_skilllevel(skillid)); + target.attack(iter->second); - return iter != actions.end() ? iter->second : ""; + return iter->second; } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Combat/SkillAction.h b/HeavenClient/Gameplay/Combat/SkillAction.h index 1cda248e6..1ebaa93ef 100644 --- a/HeavenClient/Gameplay/Combat/SkillAction.h +++ b/HeavenClient/Gameplay/Combat/SkillAction.h @@ -18,13 +18,13 @@ #pragma once #include +#include #include #include "Attack.h" #include "../../Character/Char.h" -#include "../../Template/BoolPair.h" namespace ms { @@ -34,52 +34,37 @@ namespace ms virtual ~SkillAction() {} - virtual void apply(Char &target, Attack::Type atype) const = 0; - - // The body action the skill takes up, or "" for none of its own. - virtual std::string get_action(const Char &) const - { - return ""; - } + // Plays the skill's body action, and returns it, or "" if it has + // none of its own. + virtual std::string apply(Char &target, Attack::Type atype) const = 0; }; class NoAction : public SkillAction { public: - void apply(Char &, Attack::Type) const override - {} + std::string apply(Char &, Attack::Type) const override + { + return ""; + } }; class RegularAction : public SkillAction { public: - void apply(Char &target, Attack::Type atype) const override; - }; - - class SingleAction : public SkillAction - { - public: - SingleAction(nl::node src); - - void apply(Char &target, Attack::Type atype) const override; - - std::string get_action(const Char &target) const override; - - private: - std::string action; + std::string apply(Char &target, Attack::Type atype) const override; }; - class TwoHandedAction : public SkillAction + // v83 takes one of a skill's actions at random each time + // (FUN_007660b6), so most skills, having one, always take it. + class RandomAction : public SkillAction { public: - TwoHandedAction(nl::node src); + RandomAction(nl::node src); - void apply(Char &target, Attack::Type atype) const override; - - std::string get_action(const Char &target) const override; + std::string apply(Char &target, Attack::Type atype) const override; private: - BoolPair actions; + std::vector actions; }; class ByLevelAction : public SkillAction @@ -87,9 +72,7 @@ namespace ms public: ByLevelAction(nl::node src, int32_t skillid); - void apply(Char &target, Attack::Type atype) const override; - - std::string get_action(const Char &target) const override; + std::string apply(Char &target, Attack::Type atype) const override; private: std::map actions; diff --git a/HeavenClient/Gameplay/Combat/SpecialMove.h b/HeavenClient/Gameplay/Combat/SpecialMove.h index 916e1bff1..be28bcdb8 100644 --- a/HeavenClient/Gameplay/Combat/SpecialMove.h +++ b/HeavenClient/Gameplay/Combat/SpecialMove.h @@ -46,13 +46,9 @@ namespace ms virtual void apply_useeffects(Char &user) const = 0; - virtual void apply_actions(Char &user, Attack::Type type) const = 0; - - // The body action the move takes up, or "" for none of its own. - virtual std::string get_action(const Char &) const - { - return ""; - } + // Plays the move's body action, and returns it, or "" if it has + // none of its own. + virtual std::string apply_actions(Char &user, Attack::Type type) const = 0; virtual void apply_stats(const Char &user, Attack &attack) const = 0; From fe4f6e9b97639a3102bfbf83bbf7aac6aac3cd76 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 00:55:58 -0400 Subject: [PATCH 5/5] L144: Heal as in v83 (0x96a69d) The client sends USE_SKILL with the party mask, then casts at the undead in 60% of the skill's area, in the order they entered the map, each hit from the Heal formula (0x791aa8) shared by everyone it reaches. The server heals the cleric by their share and caps Heal's hits by the same formula. --- HeavenClient/CMakeLists.txt | 3 + HeavenClient/Data/SkillData.cpp | 3 +- HeavenClient/Gameplay/Combat/Attack.h | 4 + HeavenClient/Gameplay/Combat/Combat.cpp | 127 +++++++++++++++++------ HeavenClient/Gameplay/Combat/Combat.h | 12 +++ HeavenClient/Gameplay/Combat/Heal.h | 48 +++++++++ HeavenClient/Gameplay/Combat/Skill.cpp | 6 ++ HeavenClient/Gameplay/MapleMap/Mob.cpp | 11 +- HeavenClient/Gameplay/MapleMap/Mob.h | 3 + HeavenClient/tests/heal.cpp | 43 ++++++++ net/src/combat.rs | 18 +++- net/src/packet/handle/world/combat.rs | 2 +- net/src/packet/handle/world/use_skill.rs | 70 +++++++++++++ net/src/skills.rs | 44 +++++++- 14 files changed, 359 insertions(+), 35 deletions(-) create mode 100644 HeavenClient/Gameplay/Combat/Heal.h create mode 100644 HeavenClient/tests/heal.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index c443c2901..1408712fa 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -118,6 +118,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(spell_area_tests tests/spell_area.cpp) target_include_directories(spell_area_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME spell_area COMMAND spell_area_tests) + add_executable(heal_tests tests/heal.cpp) + target_include_directories(heal_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME heal COMMAND heal_tests) add_executable(teleport_tests tests/teleport.cpp) target_include_directories(teleport_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME teleport COMMAND teleport_tests) diff --git a/HeavenClient/Data/SkillData.cpp b/HeavenClient/Data/SkillData.cpp index 824092345..bfcbf683a 100644 --- a/HeavenClient/Data/SkillData.cpp +++ b/HeavenClient/Data/SkillData.cpp @@ -190,6 +190,7 @@ namespace ms 4111004, 4121003, 4221003, 5121002, 11101004, 13101005, 14101006, 15111007, 21100004, 21110004, 21120006 }; + // Heal has its own case (0x96a69d), but casts as these do (0x95571f). static const std::unordered_set spells = { 2001004, 2001005, @@ -199,7 +200,7 @@ namespace ms 2201004, 2201005, 2211002, 2211003, 2211006, 2221001, 2221003, 2221006, 2221007, - 2301005, + 2301002, 2301005, 2311004, 2321001, 2321007, 2321008, 12001003, diff --git a/HeavenClient/Gameplay/Combat/Attack.h b/HeavenClient/Gameplay/Combat/Attack.h index 6594ad04e..5c8e29536 100644 --- a/HeavenClient/Gameplay/Combat/Attack.h +++ b/HeavenClient/Gameplay/Combat/Attack.h @@ -60,6 +60,10 @@ namespace ms int32_t mastery = 0; int32_t amplify = 100; uint8_t element = 0; + // Heal's `hp` percent, which it hurts the undead with in place of a + // magic attack, and how many it reaches, mobs and party members. + int32_t heal = 0; + int32_t healed = 0; uint8_t hitcount = 0; uint8_t mobcount = 0; diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 11d6b3d61..653ba6e26 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -19,6 +19,7 @@ #include "ChainLightning.h" #include "DamageLayout.h" +#include "Heal.h" #include "HitTiming.h" #include "PointBlank.h" #include "Prepare.h" @@ -359,55 +360,114 @@ namespace ms if (kinds & skill_use::PARTY) { - const Party &party = player.get_party(); + members = find_party(skill, range, reach.party); - if (!party.exists()) + if (!skill_use::sends(skill, reach.party)) + return std::nullopt; + } + + if (kinds & skill_use::MOBS) + reach.mobs = find_targets(mobs.get_mobs(), range, position, skill_use::mob_limit(stats.mobcount, members), + true, false); + + return reach; + } + + int32_t Combat::find_party(int32_t skill, Rectangle range, uint8_t &mask) const + { + const Party &party = player.get_party(); + int32_t members = 0; + + if (!party.exists()) + { + mask = skill_use::ALONE; + members = 1; + } + + for (const PartyMember &member : party.get_members()) + { + bool self = member.id == player.get_oid(); + bool dead = false; + + if (self) { - reach.party = skill_use::ALONE; - members = 1; + dead = player.is_dead(); } + else + { + Optional other = chars.get_char(member.id); + + if (!other || !range.contains(other->get_position())) + continue; - for (const PartyMember &member : party.get_members()) + dead = other->is_dead(); + } + + if (skill_use::reaches(skill, self, dead)) { - bool self = member.id == player.get_oid(); - bool dead = false; + mask |= skill_use::party_bit(member.slot); + members++; + } + } - if (self) - { - dead = player.is_dead(); - } - else - { - Optional other = chars.get_char(member.id); + return members; + } - if (!other || !range.contains(other->get_position())) - continue; + int32_t Combat::request_heal() + { + int32_t level = player.get_skills().get_level(heal::ID); + const SkillData::Stats &stats = SkillData::get(heal::ID).get_stats(level); + Rectangle range = place_range(player.get_position(), stats.range, 1.0f, !player.getflip()); - dead = other->is_dead(); - } + UseSkillPacket::Reach reach; + int32_t members = find_party(heal::ID, range, reach.party); - if (skill_use::reaches(skill, self, dead)) - { - reach.party |= skill_use::party_bit(member.slot); - members++; - } - } + player.request(UseSkillPacket(heal::ID, level, 0, reach), ExclRequest::SKILL_WAIT); - if (!skill_use::sends(skill, reach.party)) - return std::nullopt; + return members; + } + + std::vector Combat::find_undead(Rectangle range, uint8_t count) const + { + MapObjects *objs = mobs.get_mobs(); + std::multimap candidates; + + for (auto &mmo : *objs) + { + const Mob *mob = static_cast(mmo.second.get()); + + if (mob && mob->is_alive() && mob->is_undead() && mob->is_in_range(range)) + candidates.emplace(objs->entry_order(mob->get_oid()), mob->get_oid()); } - if (kinds & skill_use::MOBS) - reach.mobs = find_targets(mobs.get_mobs(), range, position, skill_use::mob_limit(stats.mobcount, members), - true, false); + std::vector targets; - return reach; + for (auto &candidate : candidates) + { + if (targets.size() >= count) + break; + + targets.push_back(candidate.second); + } + + return targets; } void Combat::apply_move(const SpecialMove &move, int32_t charge, bool held) { if (move.is_attack()) { + // Heal asks the server first, as a skill that doesn't attack. + int32_t healed = 0; + + if (move.get_id() == heal::ID) + { + if (!player.can_request(ExclRequest::SKILL_WAIT)) + return; + + healed = request_heal(); + } + Attack attack = player.prepare_attack(move.is_skill() ? move.get_id() : 0); attack.charge = charge; Stance::Id whack = Stance::Id::NONE; @@ -527,6 +587,13 @@ namespace ms } } } + } else if (attack.skill == heal::ID) + { + // It hurts fewer mobs the more of the party it heals, and each + // less the more it reaches. + range = place_range(origin, heal::area(attack.range), 1.0f, attack.toleft); + mob_targets = find_undead(range, skill_use::mob_limit(mobcount, healed)); + attack.healed = healed + static_cast(mob_targets.size()); } else { mob_targets = find_targets(mob_objs, range, origin, mobcount, true, closest_first); diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index f53f52e3c..beea861c7 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -126,6 +126,18 @@ namespace ms // to raise. std::optional find_reach(int32_t skill, int32_t level); + // The party members a skill reaches in `range`, a bit for each in + // `mask`, and how many (0x96db3f). + int32_t find_party(int32_t skill, Rectangle range, uint8_t &mask) const; + + // Tell the server whom in the party Heal heals (0x96a69d), and how + // many. + int32_t request_heal(); + + // The undead in `range` that Heal hurts, at most `count`, in the + // order they entered the map (FUN_00678760). + std::vector find_undead(Rectangle range, uint8_t count) const; + // Use the move. A held skill hits with its `charge`, and its shots // while held keep the body's action (`held`). void apply_move(const SpecialMove &move, int32_t charge = 0, bool held = false); diff --git a/HeavenClient/Gameplay/Combat/Heal.h b/HeavenClient/Gameplay/Combat/Heal.h new file mode 100644 index 000000000..22e13cefc --- /dev/null +++ b/HeavenClient/Gameplay/Combat/Heal.h @@ -0,0 +1,48 @@ +#pragma once + +#include "MagicDamage.h" + +#include "../../Template/Rectangle.h" + +#include + +namespace ms +{ + // Heal, as in the MapleRoyals client (FUN_0096a69d): it tells the server + // whom in the party it heals, then hurts the undead around the user as + // a spell. + namespace heal + { + constexpr int32_t ID = 2301002; + + // It hits the undead in 60% of the skill's area (0x9563a8). + inline Rectangle area(const Rectangle &range) + { + auto scale = [](int16_t side) + { + return static_cast(side * 0.6); + }; + + return { + scale(range.left()), + scale(range.right()), + scale(range.top()), + scale(range.bottom()) + }; + } + + // What a hit on an undead monster comes from in place of the spell's + // magic attack (0x791aa8): the skill's `hp` percent, the player's + // magic, INT and LUK, and how many it reaches, mobs and party members + // together. The more it reaches, the less each gets. + inline double base(int32_t hp, int32_t magic, int32_t intl, int32_t luk, int32_t reached, double roll) + { + if (reached < 1) + reached = 1; + + double rolled = magic_damage::between(intl * 0.2, intl * 0.8, roll); + + return (reached * 0.3 + 1.0) * hp * 0.01 * (rolled * 1.5 + luk) * magic * 0.005 / reached; + } + } +} diff --git a/HeavenClient/Gameplay/Combat/Skill.cpp b/HeavenClient/Gameplay/Combat/Skill.cpp index 83a8c7079..9eebf1cd0 100644 --- a/HeavenClient/Gameplay/Combat/Skill.cpp +++ b/HeavenClient/Gameplay/Combat/Skill.cpp @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #include "Skill.h" +#include "Heal.h" #include "Prepare.h" #include "../../Character/SkillId.h" @@ -223,6 +224,11 @@ namespace ms { attack.fixdamage = stats.fixdamage; attack.damagetype = Attack::DMG_FIXED; + } else if (skillid == heal::ID) + { + attack.heal = stats.hp; + attack.element = SkillData::get(skillid).get_element(); + attack.damagetype = Attack::DMG_MAGIC; } else if (stats.matk) { attack.matk += stats.matk; diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index 15e247ed6..4913e558a 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -18,6 +18,7 @@ #include "Mob.h" #include "Layer.h" +#include "../Combat/Heal.h" #include "../Combat/HitPoint.h" #include "../Combat/MonsterMagnet.h" #include "../Combat/Prepare.h" @@ -1181,7 +1182,10 @@ namespace ms if (!hits(accuracy, avoid, hitroll)) return 0; - double damage = base(attack.magic, attack.intelligence, attack.mastery, attack.matk, randomizer.next_real(1.0)); + double roll = randomizer.next_real(1.0); + double damage = attack.heal + ? heal::base(attack.heal, attack.magic, attack.intelligence, attack.luck, attack.healed, roll) + : base(attack.magic, attack.intelligence, attack.mastery, attack.matk, roll); damage = damage * attack.amplify * 0.01; damage = elemental(damage, attack.skill, attack.element, elements); damage = defend(damage, mdef, randomizer.next_real(1.0)); @@ -1276,6 +1280,11 @@ namespace ms return blocksteleport; } + bool Mob::is_undead() const + { + return undead; + } + bool Mob::is_boss() const { return boss; diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index f4457fdd9..0d53d8054 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -149,6 +149,9 @@ namespace ms // Whether Monster Magnet can't catch the mob. bool is_boss() const; + // Whether Heal hurts the mob. + bool is_undead() const; + // Whether the mob walks or flies. bool can_move() const; diff --git a/HeavenClient/tests/heal.cpp b/HeavenClient/tests/heal.cpp new file mode 100644 index 000000000..e38b69ae4 --- /dev/null +++ b/HeavenClient/tests/heal.cpp @@ -0,0 +1,43 @@ +#include "../Gameplay/Combat/Heal.h" + +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +bool same(ms::Rectangle a, ms::Rectangle b) +{ + return a.left() == b.left() && a.right() == b.right() && a.top() == b.top() && a.bottom() == b.bottom(); +} + +bool near(double a, double b) +{ + return std::fabs(a - b) < 1e-6; +} + +int main() +{ + try + { + using namespace ms; + using namespace ms::heal; + + require(same(area({ -300, 300, -200, 200 }), { -180, 180, -120, 120 }), "60% of the skill's area"); + require(same(area({ -251, 251, -151, 149 }), { -150, 150, -90, 89 }), "cut towards the middle"); + + require(near(base(300, 500, 400, 100, 1, 0.0), 2145.0), "the least, alone"); + require(near(base(300, 500, 400, 100, 1, 1.0), 5655.0), "the most rolls INT * 0.8"); + require(near(base(300, 500, 400, 100, 4, 0.0), 907.5), "reaching more, each gets less"); + require(near(base(300, 500, 400, 100, 0, 0.0), 2145.0), "the user always counts"); + } catch (const std::exception &e) + { + std::cerr << e.what() << '\n'; + return 1; + } + + return 0; +} diff --git a/net/src/combat.rs b/net/src/combat.rs index 3092296c7..133af5b9c 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -280,6 +280,15 @@ pub enum AttackRejection { Ammo, } +/// The MP a skill's attack spends: none for Heal, whose USE_SKILL paid. +pub fn attack_mp_cost(skill: &Learned) -> i32 { + if skill.skill_id == skills::HEAL { + 0 + } else { + skill.effect.mp_cost + } +} + /// Basic attacks hit one mob once; each hit must stay under twice the base /// maximum (room for the client's own rolls, never a multiple of it). A /// skill attack takes its limits from the skill's level: its mob and hit @@ -305,12 +314,19 @@ pub fn validate(attack: &Attack, player: &Player) -> Result { + let mp = ctx.player.stats.mp; + Ok(HandlerResult::reply(stat::build_stat_update( + true, + &[StatUpdate::Hp(hp), StatUpdate::Mp(mp)], + )?)) + } + Err(refusal) => { + debug!(skill_id, level, ?refusal, "Heal refused"); + Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)) + } + }; + } match use_skill(ctx.player, skill_id, level, Instant::now()) { Ok(used) => announce(ctx.player, &used), Err(refusal) => { @@ -205,6 +221,26 @@ fn announce_magnet( .with(WorldAction::Field(FieldRequest::Magnet { effect, caught }))) } +/// 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 { + if player.stats.hp <= 0 { + return Err(Refusal::Dead); + } + let skill = skills::learned(player, skills::HEAL) + .filter(|skill| skill.level == level) + .ok_or(Refusal::NotLearned)?; + if i32::from(player.stats.mp) < skill.effect.mp_cost { + return Err(Refusal::NotEnoughMp); + } + player.add_mp(-skill.effect.mp_cost); + let reached = party.count_ones() as i32; + let amount = skills::heal_amount(player, skill.effect.hp, reached, roll); + Ok(player.add_hp(amount as i32)) +} + /// The buff a skill gives, and its value at this level. fn buff_of(skill: &Learned) -> Option<(BuffStat, i32)> { match skill.skill_id % 10_000_000 { @@ -430,6 +466,40 @@ mod tests { ); } + #[test] + fn heal_heals_the_cleric_by_their_share() { + if skills::data().is_err() { + return; + } + let cleric = || { + let mut player = beginner_with(skills::HEAL, 30); + player.stats.int = 400; + player.stats.luk = 100; + player.stats.max_hp = 10_000; + player.stats.mp = 100; + player.stats.max_mp = 100; + player + }; + // 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)); + // 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)); + // Heal spends the MP, not its attack. + let cost = skills::learned(&player, skills::HEAL) + .unwrap() + .effect + .mp_cost; + assert!(cost > 0); + assert_eq!(i32::from(player.stats.mp), 100 - cost); + + assert_eq!(heal(&mut player, 29, 0x80, 0.0), Err(Refusal::NotLearned)); + player.stats.mp = 0; + assert_eq!(heal(&mut player, 30, 0x80, 0.0), Err(Refusal::NotEnoughMp)); + } + #[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 b80904c8d..4fa3734c4 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::Player; +use game::{InventoryType, Player}; use game_data::skills::{SkillData, SkillInfo, SkillLevel}; use std::sync::OnceLock; @@ -109,6 +109,48 @@ pub fn amplification(player: &Player) -> i32 { .map_or(100, |skill| skill.effect.y) } +/// Heal: it heals the party around the cleric and hurts the undead. +pub const HEAL: i32 = 2301002; + +/// What a spell's damage comes from, with the player's equipment: their INT, +/// their LUK, and their magic, INT plus magic attack, at most 1999. +pub fn spell_stats(player: &Player) -> (i32, i32, i32) { + let (int, luk, matk) = player + .inventory + .items(InventoryType::Equipped) + .filter_map(|(_, item)| item.equip.as_ref()) + .fold( + (i32::from(player.stats.int), i32::from(player.stats.luk), 0), + |(int, luk, matk), equip| { + ( + int + i32::from(equip.int), + luk + i32::from(equip.luk), + matk + i32::from(equip.matk), + ) + }, + ); + (int, luk, (int + matk).clamp(0, 1999)) +} + +/// What Heal does to each of the `reached` it reaches, party members and +/// undead together, before the undead's defense: the v83 client's formula +/// for its hurt (FUN_00791617, 0x791aa8), from the skill's `hp` percent. +/// `roll`, from 0 to 1, picks between 20% and 80% of the INT. The more it +/// reaches, the less each gets. +pub fn heal_amount(player: &Player, hp: i32, reached: i32, roll: f64) -> f64 { + let (int, luk, magic) = spell_stats(player); + let reached = f64::from(reached.max(1)); + let int = f64::from(int); + let rolled = int * 0.2 + (int * 0.8 - int * 0.2) * roll; + (reached * 0.3 + 1.0) + * f64::from(hp) + * 0.01 + * (rolled * 1.5 + f64::from(luk)) + * f64::from(magic) + * 0.005 + / reached +} + /// Whether `job` is `base` or advanced from it, as the v83 client checks: /// 410 covers 410 to 412, and 100 every warrior. fn in_line(job: i32, base: i32) -> bool {