From 2be8dd1f376b21c6e1c87d7a5e6bd98f7424bb71 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 10:16:50 -0400 Subject: [PATCH 1/4] L158: summons (Puppet, Silver Hawk, Octopus, Elquines, souls...) Server: USE_SKILL casts a summon at the client's spot (within 250x150 of the owner), spending MP and itemCon and giving its buff; the field keeps summons, shows SPAWN/REMOVE/MOVE_SUMMON, checks SUMMON_ATTACK against mobCount and a 2x damage cap, breaks Puppets at their x HP, and brings followers along on a map change. HeavenClient: MapSummons draws and animates summons (v83 CSummoned); SummonRules holds follow/circle movement, placement and damage; Combat places the cast, targets and attacks on the owner's 3 s / 1.5 s clock, and plays others' attacks; chat lines on expiry. Harness: summons test. Docs: combat.md Summons. Report 158. --- HeavenClient/CMakeLists.txt | 5 + HeavenClient/Character/Buff.cpp | 1 + HeavenClient/Gameplay/Combat/Combat.cpp | 229 ++++++++- HeavenClient/Gameplay/Combat/Combat.h | 48 +- HeavenClient/Gameplay/MapleMap/MapMobs.cpp | 17 + HeavenClient/Gameplay/MapleMap/MapMobs.h | 6 + HeavenClient/Gameplay/MapleMap/MapSummons.cpp | 443 ++++++++++++++++++ HeavenClient/Gameplay/MapleMap/MapSummons.h | 167 +++++++ HeavenClient/Gameplay/MapleMap/Mob.cpp | 5 + HeavenClient/Gameplay/MapleMap/Mob.h | 2 + HeavenClient/Gameplay/MapleMap/SummonRules.h | 360 ++++++++++++++ HeavenClient/Gameplay/Stage.cpp | 14 +- HeavenClient/Gameplay/Stage.h | 5 + .../Net/Handlers/MapObjectHandlers.cpp | 98 ++++ HeavenClient/Net/Handlers/MapObjectHandlers.h | 42 ++ HeavenClient/Net/OutPacket.h | 3 + HeavenClient/Net/PacketSwitch.cpp | 12 + .../Net/Packets/AttackAndSkillPackets.h | 62 +++ HeavenClient/Net/Packets/GameplayPackets.h | 13 + HeavenClient/tests/summon_rules.cpp | 105 +++++ agent_docs/combat.md | 80 +++- game/src/buff.rs | 3 + integration-harness/src/hunting.rs | 104 ++++ integration-harness/tests/summons.rs | 126 +++++ net/src/handler.rs | 35 +- net/src/lib.rs | 1 + net/src/packet/build/world/mod.rs | 1 + net/src/packet/build/world/summon.rs | 217 +++++++++ net/src/packet/handle/world/mod.rs | 2 + net/src/packet/handle/world/summon.rs | 75 +++ net/src/packet/handle/world/use_skill.rs | 42 +- net/src/packet/op/recv.rs | 3 + net/src/packet/op/send.rs | 5 + net/src/skills.rs | 6 +- net/src/summons.rs | 355 ++++++++++++++ runtime/src/actor/client.rs | 2 + runtime/src/actor/client/buff.rs | 75 ++- runtime/src/actor/field.rs | 367 ++++++++++++++- runtime/src/actor/field_summons.rs | 177 +++++++ runtime/src/actor/mod.rs | 1 + runtime/src/actor/object_ids.rs | 1 + runtime/src/message.rs | 4 + 42 files changed, 3303 insertions(+), 16 deletions(-) create mode 100644 HeavenClient/Gameplay/MapleMap/MapSummons.cpp create mode 100644 HeavenClient/Gameplay/MapleMap/MapSummons.h create mode 100644 HeavenClient/Gameplay/MapleMap/SummonRules.h create mode 100644 HeavenClient/tests/summon_rules.cpp create mode 100644 integration-harness/tests/summons.rs create mode 100644 net/src/packet/build/world/summon.rs create mode 100644 net/src/packet/handle/world/summon.rs create mode 100644 net/src/summons.rs create mode 100644 runtime/src/actor/field_summons.rs diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index b784ded0d..789a805a2 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -159,6 +159,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME emote_cooldown COMMAND emote_cooldown_tests) add_executable(fame_cooldown_tests tests/fame_cooldown.cpp) add_test(NAME fame_cooldown COMMAND fame_cooldown_tests) + add_executable(summon_rules_tests tests/summon_rules.cpp) + add_test(NAME summon_rules COMMAND summon_rules_tests) add_executable(chat_flood_guard_tests tests/chat_flood_guard.cpp) add_test(NAME chat_flood_guard COMMAND chat_flood_guard_tests) add_executable(char_click_tests tests/char_click.cpp) @@ -344,6 +346,9 @@ add_executable(HeavenClient Gameplay/MapleMap/MapInfo.h Gameplay/MapleMap/MapMists.cpp Gameplay/MapleMap/MapMists.h + Gameplay/MapleMap/MapSummons.cpp + Gameplay/MapleMap/MapSummons.h + Gameplay/MapleMap/SummonRules.h Gameplay/MapleMap/MapMobs.cpp Gameplay/MapleMap/MapMobs.h Gameplay/MapleMap/MapNpcs.cpp diff --git a/HeavenClient/Character/Buff.cpp b/HeavenClient/Character/Buff.cpp index 091a6f853..1793f9bc9 100644 --- a/HeavenClient/Character/Buff.cpp +++ b/HeavenClient/Character/Buff.cpp @@ -69,6 +69,7 @@ namespace ms case 43: return HAMSTRING; case 44: return BLIND; case 45: return CONCENTRATE; + case 46: return PUPPET; case 47: return ECHO_OF_HERO; case 49: return GHOST_MORPH; case 82: return ENERGY_CHARGE; diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 296142f59..9b3152538 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -21,6 +21,7 @@ #include "DamageLayout.h" #include "Heal.h" #include "HitTiming.h" +#include "MagicDamage.h" #include "PointBlank.h" #include "Prepare.h" #include "RemoteShot.h" @@ -31,6 +32,7 @@ #include "../../Character/SkillId.h" #include "../../Data/SkillData.h" #include "../../IO/Messages.h" +#include "../../Util/Misc.h" #include "../../Util/Randomizer.h" #include "../../Net/Packets/AttackAndSkillPackets.h" @@ -44,8 +46,9 @@ namespace ms { Combat::Combat(Player &in_player, MapChars &in_chars, MapMobs &in_mobs, MapReactors &in_reactors, - const Physics &in_physics, const MapInfo &in_mapinfo) : player( - in_player), chars(in_chars), mobs(in_mobs), reactors(in_reactors), physics(in_physics), mapinfo(in_mapinfo), + MapSummons &in_summons, const Physics &in_physics, const MapInfo &in_mapinfo) : player( + in_player), chars(in_chars), mobs(in_mobs), reactors(in_reactors), summons(in_summons), + physics(in_physics), mapinfo(in_mapinfo), attackresults( [&](const AttackResult &attack) { @@ -64,6 +67,11 @@ namespace ms apply_damage_effect( effect); }), + summonhits( + [&](const SummonHit &hit) + { + apply_summon_hit(hit); + }), magnetcatches( [&](const MagnetCatch &caught) { @@ -153,10 +161,12 @@ namespace ms update_remote_prepares(); update_grenades(); update_rains(); + update_summons(); attackresults.update(); bulleteffects.update(); damageeffects.update(); + summonhits.update(); magnetcatches.update(); fieldeffects.update(); @@ -314,6 +324,10 @@ namespace ms int32_t moveid = move.get_id(); int32_t level = player.get_skills().get_level(moveid); + + if (summon_rules::is_summon(moveid)) + return request_summon(move, level); + std::optional reach = find_reach(moveid, level); if (!reach) @@ -329,6 +343,217 @@ namespace ms return true; } + bool Combat::request_summon(const SpecialMove &move, int32_t level) + { + int32_t moveid = move.get_id(); + bool left = !player.getflip(); + Point position = player.get_position(); + Range walls = mapinfo.get_walls(); + auto ground = [&](int16_t x, int16_t top) + { + return physics.get_fht().get_y_below(Point(x, top)); + }; + auto spot = summon_rules::place(moveid, position.x(), position.y(), left, walls.first(), walls.second(), + ground); + + // v83 says nothing when there's no room for it. + if (!spot) + return false; + + move.apply_useeffects(player); + move.apply_actions(player, Attack::Type::MAGIC); + player.fix_action_speed(6); + player.request(SummonSkillPacket(moveid, level, Point(spot->first, spot->second), left), + ExclRequest::SKILL_WAIT); + + return true; + } + + const Combat::SummonInfo &Combat::get_summon_info(int32_t skill) + { + auto iter = summoninfos.find(skill); + + if (iter != summoninfos.end()) + return iter->second; + + nl::node src = nl::nx::skill[string_format::extend_id(skill / 10000, 3) + ".img"]["skill"] + [string_format::extend_id(skill, 7)]; + nl::node info = src["summon"]["attack1"]["info"]; + nl::node range = info["range"]; + nl::node hit = info["hit"] ? info["hit"] : src["hit"]["0"]; + + SummonInfo summon{ + static_cast(info["mobCount"] ? info["mobCount"].get_integer() : 1), + static_cast(range["r"].get_integer()), + range["lt"] ? Rectangle(range["lt"], range["rb"]) : Rectangle(), + static_cast(info["attackAfter"].get_integer()), + static_cast(info["ball"] ? info["bulletSpeed"].get_integer() : 0), + Animation(hit) + }; + + return summoninfos.emplace(skill, summon).first->second; + } + + void Combat::update_summons() + { + if (player.is_dead()) + return; + + for (auto &[oid, summon] : summons.get_local()) + { + int32_t skill = summon->get_skill(); + + if (summon->get_assist() != summon_rules::ATTACK || !summon->is_idle() || + now - summon->last_attack < summon_rules::attack_interval(skill)) + continue; + + const SummonInfo &info = get_summon_info(skill); + Point position = summon->get_position(); + MapObjects *objs = mobs.get_mobs(); + std::vector targets; + bool left = summon->is_left(); + + if (summon_rules::aims_at_box(skill)) + { + // The mage summons hit the side of their box with more mobs + // (FUN_00765047). + Rectangle area = info.area; + Rectangle facing_left(position + area.get_left_top(), position + area.get_right_bottom()); + Rectangle facing_right(position.x() - area.right(), position.x() - area.left(), + position.y() + area.top(), position.y() + area.bottom()); + std::vector lefts = find_targets(objs, facing_left, position, info.mobcount, true, false); + std::vector rights = find_targets(objs, facing_right, position, info.mobcount, true, false); + left = lefts.size() >= rights.size(); + targets = left ? lefts : rights; + } else + { + // The others hit the mobs nearest them, within their reach + // (I: within 100 px up or down). + int16_t reach = info.reach > 0 ? info.reach : 300; + Rectangle around(position.x() - reach, position.x() + reach, position.y() - 100, + position.y() + 100); + uint8_t count = summon_rules::spreads(skill) ? 1 : info.mobcount; + targets = find_targets(objs, around, position, count, true, true); + + if (!targets.empty() && summon_rules::spreads(skill)) + { + Point first = mobs.get_mob_position(targets[0]); + Rectangle spread(first + info.area.get_left_top(), first + info.area.get_right_bottom()); + + for (int32_t other : find_targets(objs, spread, first, info.mobcount, true, true)) + if (other != targets[0] && targets.size() < info.mobcount) + targets.push_back(other); + } + + if (!targets.empty()) + left = mobs.get_mob_position(targets[0]).x() < position.x(); + } + + if (targets.empty()) + continue; + + nl::node level = nl::nx::skill[string_format::extend_id(skill / 10000, 3) + ".img"]["skill"] + [string_format::extend_id(skill, 7)]["level"][std::to_string(summon->get_level())]; + const CharStats &stats = player.get_stats(); + int32_t str = stats.get_total(EquipStat::Id::STR); + int32_t dex = stats.get_total(EquipStat::Id::DEX); + int32_t intl = stats.get_total(EquipStat::Id::INT); + int32_t luk = stats.get_total(EquipStat::Id::LUK); + int32_t main = str; + + switch (summon_rules::main_stat(stats.get_job().get_id())) + { + case summon_rules::DEX: + main = dex; + break; + case summon_rules::LUK: + main = luk; + break; + default: + break; + } + + Randomizer random; + uint8_t action = skill == summon_rules::WRATH_OF_THE_OCTOPI && random.next_bool() ? summon_rules::ATTACK2 + : summon_rules::ATTACK1; + std::vector hits; + + for (size_t i = 0; i < targets.size(); i++) + { + int32_t target = targets[i]; + double roll = random.next_real(1.0); + int32_t damage; + + if (summon_rules::is_magic(skill)) + { + int32_t magic = magic_damage::magic(intl, stats.get_total(EquipStat::Id::MAGIC)); + damage = std::max(1, static_cast( + magic_damage::base(magic, intl, 0, level["mad"].get_integer(), roll))); + } else + { + damage = summon_rules::physical_damage(main, str + dex + luk - main, + static_cast(level["pad"].get_integer()), roll); + } + + Point at = mobs.get_mob_position(target); + int16_t delay = info.attackafter; + + if (info.bulletspeed > 0) + delay += static_cast(at.distance(position) * 1000 / info.bulletspeed); + + if (summon_rules::spreads(skill)) + delay += static_cast(120 * i); + + hits.push_back({ target, mobs.get_mob_id(target), at, at.x() > position.x(), delay, damage }); + summonhits.push(delay, { target, damage, left, skill }); + } + + summon->set_left(left); + summon->play(static_cast(action)); + summon->last_attack = now; + + SummonAttackPacket(oid, action, left, player.get_position(), position, hits).dispatch(); + } + } + + void Combat::apply_summon_hit(const SummonHit &hit) + { + if (!mobs.contains(hit.oid)) + return; + + damagenumbers.emplace_back(DamageNumber::Type::NORMAL, hit.damage, damage_layout::mob_offset(0, false)); + damagenumbers.back().set_anchor(mobs.get_mob_head_position(hit.oid)); + mobs.apply_summon_damage(hit.oid, hit.damage, hit.toleft, get_summon_info(hit.skill).hit); + } + + void Combat::show_summon_attack(int32_t oid, uint8_t action, const std::vector> &hits) + { + Summon *summon = summons.get(oid); + + if (!summon) + return; + + bool left = action & 0x80; + int32_t skill = summon->get_skill(); + const SummonInfo &info = get_summon_info(skill); + Point position = summon->get_position(); + summon->play(static_cast(action & 0x7F)); + + for (size_t i = 0; i < hits.size(); i++) + { + int32_t delay = info.attackafter; + + if (info.bulletspeed > 0) + delay += static_cast(mobs.get_mob_position(hits[i].first).distance(position) * 1000 / + info.bulletspeed); + + if (summon_rules::spreads(skill)) + delay += static_cast(120 * i); + + summonhits.push(delay, { hits[i].first, hits[i].second, left, skill }); + } + } + std::optional Combat::find_reach(int32_t skill, int32_t level) { UseSkillPacket::Reach reach; diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index 575f99747..9175642fb 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -30,6 +30,7 @@ #include "../MapleMap/MapInfo.h" #include "../MapleMap/MapMobs.h" #include "../MapleMap/MapReactors.h" +#include "../MapleMap/MapSummons.h" #include "../Physics/Physics.h" #include "../../Character/Player.h" @@ -42,8 +43,8 @@ namespace ms class Combat { public: - Combat(Player &player, MapChars &chars, MapMobs &mobs, MapReactors &reactors, const Physics &physics, - const MapInfo &mapinfo); + Combat(Player &player, MapChars &chars, MapMobs &mobs, MapReactors &reactors, MapSummons &summons, + const Physics &physics, const MapInfo &mapinfo); // Load the charge gauge, once the NX files are open. static void init(); @@ -107,6 +108,11 @@ namespace ms // Drop the grenades in flight, as on leaving the map. void clear_grenades(); + // Show another user's summon attacking: its animation, then each + // mob's hit as it gets there (FUN_007a6882). `hits` are the mobs + // and their damage. + void show_summon_attack(int32_t oid, uint8_t action, const std::vector> &hits); + private: struct DamageEffect { @@ -126,6 +132,41 @@ namespace ms // Use a skill that doesn't attack. Whether it could ask the server. bool request_skill(const SpecialMove &move); + // Summon in front of the player, if there is room (FUN_0096b140). + // Whether it could ask the server. + bool request_summon(const SpecialMove &move, int32_t level); + + // What one of a summon's attacks is like: its + // `summon/attack1/info`. + struct SummonInfo + { + uint8_t mobcount; + // How far it reaches to either side. + int16_t reach; + // Its box, from where it is, facing left; empty if none. + Rectangle area; + int16_t attackafter; + // How fast its ball flies, in px a second; 0 if it has none. + int16_t bulletspeed; + Animation hit; + }; + + const SummonInfo &get_summon_info(int32_t skill); + + // The player's summons attack when they may (FUN_00765047). + void update_summons(); + + // A summon's hit landing on a mob. + struct SummonHit + { + int32_t oid; + int32_t damage; + bool toleft; + int32_t skill; + }; + + void apply_summon_hit(const SummonHit &hit); + // What the skill's packet carries besides it, or nothing if it isn't // sent: Shadow Stars without its stars, or Resurrection with no one // to raise. @@ -284,6 +325,7 @@ namespace ms MapChars &chars; MapMobs &mobs; MapReactors &reactors; + MapSummons &summons; const Physics &physics; const MapInfo &mapinfo; @@ -293,6 +335,8 @@ namespace ms TimedQueue attackresults; TimedQueue bulleteffects; TimedQueue damageeffects; + TimedQueue summonhits; + std::unordered_map summoninfos; // A mob Monster Magnet reached, caught or not, for the user where // they stood. diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp index f1afca2f1..de47397dd 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp @@ -151,6 +151,15 @@ namespace ms } } + void MapMobs::apply_summon_damage(int32_t oid, int32_t damage, bool toleft, const Animation &hit) + { + if (Optional mob = mobs.get(oid)) + { + mob->apply_damage(damage, toleft); + mob->show_hit(hit, mob->get_body(), toleft); + } + } + void MapMobs::expect_hits(int32_t oid, int32_t count) { if (Optional mob = mobs.get(oid)) @@ -215,6 +224,14 @@ namespace ms return Point(0, 0); } + int32_t MapMobs::get_mob_id(int32_t oid) const + { + if (Optional mob = mobs.get(oid)) + return mob->get_id(); + + return 0; + } + std::vector, int32_t>> MapMobs::take_hurts() { std::vector, int32_t>> hurts; diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.h b/HeavenClient/Gameplay/MapleMap/MapMobs.h index 2ce6614ab..4a0bb795b 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.h +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.h @@ -78,6 +78,9 @@ namespace ms // Applies damage to a mob. void apply_damage(int32_t oid, int32_t damage, bool toleft, const AttackUser &user, const SpecialMove &move); + // A summon's hit lands on a mob, with the summon's spark. + void apply_summon_damage(int32_t oid, int32_t damage, bool toleft, const Animation &hit); + // Monster Magnet caught a mob for the user at `user`. void catch_mob(int32_t oid, Point user); @@ -96,6 +99,9 @@ namespace ms // Return the head position of a mob. Point get_mob_head_position(int32_t oid) const; + // A mob's template id, or 0 if there is no such mob. + int32_t get_mob_id(int32_t oid) const; + // The numbers Poison, Venom and Ambush showed on each mob since last // asked, with where its head is. std::vector, int32_t>> take_hurts(); diff --git a/HeavenClient/Gameplay/MapleMap/MapSummons.cpp b/HeavenClient/Gameplay/MapleMap/MapSummons.cpp new file mode 100644 index 000000000..474928a39 --- /dev/null +++ b/HeavenClient/Gameplay/MapleMap/MapSummons.cpp @@ -0,0 +1,443 @@ +////////////////////////////////////////////////////////////////////////////////// +// 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 "MapSummons.h" + +#include "../../Constants.h" + +#include "../../Net/Packets/GameplayPackets.h" +#include "../../Util/Misc.h" + +#include + +#include + +namespace ms +{ + using namespace summon_rules; + + namespace + { + // How long a hit makes it blink, and a miss keeps it from another + // (FUN_007a6ebe). + constexpr int16_t HIT_BLINK = 1500; + + Movement at(double x, double y, double vx, double vy, uint8_t action, uint8_t command = 0) + { + return Movement(Movement::ABSOLUTE, command, static_cast(std::lround(x)), + static_cast(std::lround(y)), static_cast(std::lround(vx)), + static_cast(std::lround(vy)), 0, action, 0); + } + } + + Summon::Summon(int32_t owner, int32_t skill, uint8_t level, Point position, uint8_t action, + uint8_t moves, uint8_t assist, bool appear, bool local) + : owner(owner), skill(skill), level(level), moves(moves), assist(assist), local(local), x(position.x()), + y(position.y()), left(action & 1), action(action), + follower{ x, y, left }, circler{ x, y, left }, anchor(position), + shown({ x, y, 0.0, 0.0, 0, action }) + { + if (appear) + oneshot = SUMMONED; + + phobj.set_x(x); + phobj.set_y(y); + recorder.reset(at(x, y, 0, 0, action)); + } + + const Animation &Summon::animation_for(Action wanted) + { + auto iter = animations.find(wanted); + + if (iter != animations.end()) + return iter->second; + + // A missing animation falls back to fly for a flier, else to stand + // (FUN_007a3acb). + nl::node src = nl::nx::skill[string_format::extend_id(skill / 10000, 3) + ".img"]["skill"] + [string_format::extend_id(skill, 7)]["summon"]; + nl::node node = src[action_name(wanted)]; + + if (!node) + node = src[flies(moves) ? "fly" : "stand"]; + + if (!node) + node = src["stand"]; + + return animations.emplace(wanted, Animation(node)).first->second; + } + + void Summon::show(Action wanted) + { + if (wanted == showing) + return; + + animation_for(wanted); + animations[wanted].reset(); + showing = wanted; + } + + void Summon::play(Action played) + { + if (dying) + return; + + oneshot = played; + showing = NONE; + } + + void Summon::receive(const std::vector &elements) + { + if (!local) + shown.receive(elements); + } + + void Summon::hurt(int32_t damage) + { + hitcountdown = damage > 0 ? HIT_BLINK : -HIT_BLINK; + + if (damage > 0) + play(HIT); + } + + bool Summon::remove(bool animated) + { + if (!animated) + { + removed = true; + + return false; + } + + oneshot = DIE; + showing = NONE; + dying = true; + + return true; + } + + uint8_t Summon::move_action() const + { + return action; + } + + std::optional Summon::update(const Physics &physics, Point owner, bool held, + Randomizer &random) + { + if (hitcountdown > 0) + hitcountdown = static_cast(std::max(0, hitcountdown - Constants::TIMESTEP)); + else if (hitcountdown < 0) + hitcountdown = static_cast(std::min(0, hitcountdown + Constants::TIMESTEP)); + + std::optional sent; + + if (!dying && local && moves != STATIONARY) + { + double ms = Constants::TIMESTEP; + double lastx = x; + double lasty = y; + bool teleported = false; + + if (moves != CIRCLE_STATIONARY && lost(owner.x() - x, owner.y() - y)) + { + x = owner.x(); + y = owner.y(); + follower = Follower{ x, y, left }; + circler = Circler{ x, y, left }; + teleported = true; + } + + uint8_t stance = 0; + + if (moves == CIRCLE_FOLLOW || moves == CIRCLE_STATIONARY) + { + Point around = moves == CIRCLE_FOLLOW ? owner : anchor; + circle(circler, around.x(), around.y(), moves == CIRCLE_STATIONARY, + [&](int32_t n) { return random.next_int(n); }, ms); + x = circler.x; + y = circler.y; + left = circler.left; + stance = 6; + } else + { + follow(follower, owner.x(), owner.y(), held, is_soul(skill) ? 42 : 70, ms); + x = follower.x; + y = follower.y; + left = follower.left; + stance = follower.moving ? 1 : 0; + } + + action = static_cast(stance << 1 | (left ? 1 : 0)); + + if (teleported) + { + recorder.add(at(x, y, 0, 0, action, 4)); + } else + { + Movement now = at(x, y, (x - lastx) * 1000 / ms, (y - lasty) * 1000 / ms, action); + recorder.step(now, Constants::TIMESTEP); + } + + if (recorder.ready(true)) + { + move_path::Path path = recorder.flush(true); + + if (!path.idle) + sent = std::move(path); + } + } else if (!dying && !local) + { + shown.step(Constants::TIMESTEP); + + const move_path::Player::State &now = shown.get_state(); + x = now.x; + y = now.y; + action = now.stance; + left = action & 1; + } + + Action wanted = pick(oneshot, action); + show(wanted); + + if (animations[wanted].update() && oneshot != NONE) + { + if (dying) + removed = true; + + oneshot = NONE; + } + + if (!flies(moves)) + { + phobj.set_x(x); + phobj.set_y(y); + physics.get_fht().update_fh(phobj); + } + + return sent; + } + + void Summon::draw(double viewx, double viewy, float alpha) const + { + auto iter = animations.find(showing); + + if (iter == animations.end()) + return; + + // It blinks while a hit holds it (FUN_007a41d4). + float opacity = hitcountdown > 0 && hitcountdown / 120 % 2 ? 0.5f : 1.0f; + Point position(static_cast(std::lround(x + viewx)), + static_cast(std::lround(y + viewy))); + iter->second.draw(DrawArgument(position, !left, opacity), alpha); + } + + bool Summon::gone() const + { + return removed; + } + + int32_t Summon::get_owner() const + { + return owner; + } + + int32_t Summon::get_skill() const + { + return skill; + } + + uint8_t Summon::get_level() const + { + return level; + } + + uint8_t Summon::get_moves() const + { + return moves; + } + + uint8_t Summon::get_assist() const + { + return assist; + } + + Point Summon::get_position() const + { + return Point(static_cast(std::lround(x)), static_cast(std::lround(y))); + } + + bool Summon::is_left() const + { + return left; + } + + void Summon::set_left(bool l) + { + left = l; + action = static_cast((action & ~1) | (l ? 1 : 0)); + follower.left = l; + circler.left = l; + } + + bool Summon::is_local() const + { + return local; + } + + bool Summon::is_flying() const + { + return flies(moves); + } + + int8_t Summon::get_layer() const + { + return phobj.fhlayer; + } + + bool Summon::is_idle() const + { + return !dying && oneshot == NONE && can_act(moves, skill); + } + + void MapSummons::spawn(int32_t owner, int32_t oid, int32_t skill, uint8_t level, Point position, + uint8_t action, uint8_t moves, uint8_t assist, bool appear, bool local) + { + auto iter = summons.find(oid); + + if (iter != summons.end()) + { + if (local && iter->second.is_local()) + return; + + summons.erase(iter); + } + + summons.emplace(std::piecewise_construct, std::forward_as_tuple(oid), + std::forward_as_tuple(owner, skill, level, position, action, moves, assist, appear, local)); + } + + int32_t MapSummons::remove(int32_t oid, uint8_t reason) + { + auto iter = summons.find(oid); + + if (iter == summons.end()) + return 0; + + Summon &summon = iter->second; + int32_t skill = summon.get_skill(); + bool animated = reason == 0 || reason == 3 || reason == 4 || (reason == 2 && is_puppet(skill)); + + if (!summon.remove(animated)) + summons.erase(iter); + + return skill; + } + + void MapSummons::remove_owner(int32_t owner) + { + for (auto iter = summons.begin(); iter != summons.end();) + { + if (iter->second.get_owner() == owner) + iter = summons.erase(iter); + else + ++iter; + } + } + + void MapSummons::move(int32_t oid, const std::vector &elements) + { + if (Summon *summon = get(oid)) + summon->receive(elements); + } + + void MapSummons::act(int32_t oid, uint8_t action) + { + if (Summon *summon = get(oid)) + summon->play(static_cast(action & 0x7F)); + } + + void MapSummons::hurt(int32_t oid, int32_t damage) + { + Summon *summon = get(oid); + + if (!summon) + return; + + summon->hurt(damage); + + // Its number over it, as a mob's on the player (FUN_007a70e6). + numbers.emplace_back(DamageNumber::Type::TOPLAYER, std::max(damage, 0)); + numbers.back().set_anchor(summon->get_position() - Point(0, 50)); + } + + void MapSummons::update(const Physics &physics, Point owner, bool held) + { + for (auto iter = summons.begin(); iter != summons.end();) + { + Summon &summon = iter->second; + + if (std::optional path = summon.update(physics, owner, held, random)) + MoveSummonPacket(iter->first, *path).dispatch(); + + if (summon.gone()) + iter = summons.erase(iter); + else + ++iter; + } + + numbers.remove_if([](DamageNumber &number) { return number.update(); }); + } + + void MapSummons::draw(int8_t layer, double viewx, double viewy, float alpha) const + { + for (const auto &iter : summons) + { + const Summon &summon = iter.second; + + if (summon.is_flying() ? layer == -1 : summon.get_layer() == layer) + summon.draw(viewx, viewy, alpha); + } + + if (layer == -1) + for (const DamageNumber &number : numbers) + number.draw(viewx, viewy, alpha); + } + + void MapSummons::clear() + { + summons.clear(); + numbers.clear(); + } + + std::vector> MapSummons::get_local() + { + std::vector> local; + + for (auto &iter : summons) + if (iter.second.is_local()) + local.emplace_back(iter.first, &iter.second); + + return local; + } + + Summon *MapSummons::get(int32_t oid) + { + auto iter = summons.find(oid); + + return iter == summons.end() ? nullptr : &iter->second; + } +} diff --git a/HeavenClient/Gameplay/MapleMap/MapSummons.h b/HeavenClient/Gameplay/MapleMap/MapSummons.h new file mode 100644 index 000000000..f79fec5af --- /dev/null +++ b/HeavenClient/Gameplay/MapleMap/MapSummons.h @@ -0,0 +1,167 @@ +////////////////////////////////////////////////////////////////////////////////// +// 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 "SummonRules.h" + +#include "../MovePath.h" +#include "../Combat/DamageNumber.h" +#include "../Physics/Physics.h" + +#include "../../Graphics/Animation.h" +#include "../../Util/Randomizer.h" + +#include +#include + +namespace ms +{ + // A user's summon: a Puppet, Silver Hawk, the Octopus, Elquines... + // (v83 CSummoned, report 158). The owner's client moves it and decides + // when it attacks; the others play what the server passes on. + class Summon + { + public: + Summon(int32_t owner, int32_t skill, uint8_t level, Point position, uint8_t action, uint8_t moves, + uint8_t assist, bool appear, bool local); + + // Play an animation once, over the move action. + void play(summon_rules::Action action); + + // Play its path, as its owner's client sent it (0xB1). + void receive(const std::vector &elements); + + // A mob hurt it: it blinks for 1.5 s, and flinches if hurt + // (FUN_007a6ebe). + void hurt(int32_t damage); + + // Go, playing `die` first if `dying`. Whether anything is left to + // show. + bool remove(bool dying); + + // Move and animate it one step. The owner's own summon follows + // `owner` (held on a ladder or rope), and returns the path to send + // once there is one. Whether it is gone is `gone()`. + std::optional update(const Physics &physics, Point owner, bool held, + Randomizer &random); + + void draw(double viewx, double viewy, float alpha) const; + + bool gone() const; + int32_t get_owner() const; + int32_t get_skill() const; + uint8_t get_level() const; + uint8_t get_moves() const; + uint8_t get_assist() const; + Point get_position() const; + bool is_left() const; + bool is_local() const; + bool is_flying() const; + int8_t get_layer() const; + // Whether it may attack: it can act, and plays nothing. + bool is_idle() const; + // When it last attacked, in the combat's ms. + int64_t last_attack = 0; + + void set_left(bool left); + + private: + const Animation &animation_for(summon_rules::Action action); + void show(summon_rules::Action action); + // The move action it shows: its stance times two, plus one facing + // left. + uint8_t move_action() const; + + int32_t owner; + int32_t skill; + uint8_t level; + uint8_t moves; + uint8_t assist; + bool local; + + double x; + double y; + bool left; + uint8_t action; + + std::map animations; + summon_rules::Action showing = summon_rules::NONE; + summon_rules::Action oneshot = summon_rules::NONE; + bool dying = false; + bool removed = false; + // The ms left of its blink after a hit; minus after a miss. + int16_t hitcountdown = 0; + + // Where it stands, for the layer it shows on. + PhysicsObject phobj; + + // The owner's: how it moves, and the path not yet sent. + summon_rules::Follower follower; + summon_rules::Circler circler; + Point anchor; + move_path::Recorder recorder; + // The others': the paths to play. + move_path::Player shown; + }; + + // The summons on the map, by object id. + class MapSummons + { + public: + // SPAWN_SUMMON (0xAF). The player's own summon whose id is known + // is kept as it is; another user's starts over (0x95adec, 0x938f61). + void spawn(int32_t owner, int32_t oid, int32_t skill, uint8_t level, Point position, uint8_t action, + uint8_t moves, uint8_t assist, bool appear, bool local); + + // REMOVE_SUMMON (0xB0): reasons 0, 3, and 4, and 2 for a Puppet, + // play `die` first. The skill it was, or 0 if there was none. + int32_t remove(int32_t oid, uint8_t reason); + + // Drop a user's summons without a trace, as they leave the map. + void remove_owner(int32_t owner); + + // MOVE_SUMMON (0xB1). + void move(int32_t oid, const std::vector &elements); + + // SUMMON_ATTACK (0xB2) and SUMMON_SKILL (0xB3): show its animation. + void act(int32_t oid, uint8_t action); + + // DAMAGE_SUMMON (0xB4): its number and its blink. + void hurt(int32_t oid, int32_t damage); + + // Move and animate them; the player's own follow `owner` and send + // their paths. + void update(const Physics &physics, Point owner, bool held); + + // The summons on a layer of the map that stand on it, or the fliers + // above all (`layer` -1). + void draw(int8_t layer, double viewx, double viewy, float alpha) const; + + void clear(); + + // The player's own summons, for their attacks. + std::vector> get_local(); + + Summon *get(int32_t oid); + + private: + std::map summons; + std::list numbers; + Randomizer random; + }; +} diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index 3198da3d2..494bc8864 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -1546,6 +1546,11 @@ namespace ms return active && !dying; } + int32_t Mob::get_id() const + { + return id; + } + bool Mob::blocks_teleport() const { return blocksteleport; diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index e41d36f25..713f25a83 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -155,6 +155,8 @@ namespace ms // Check if this mob is still alive. bool is_alive() const; + // Its template id, as Mob.wz names it. + int32_t get_id() const; // Whether a Teleport can't take the player past this mob. bool blocks_teleport() const; diff --git a/HeavenClient/Gameplay/MapleMap/SummonRules.h b/HeavenClient/Gameplay/MapleMap/SummonRules.h new file mode 100644 index 000000000..a5bad0c3b --- /dev/null +++ b/HeavenClient/Gameplay/MapleMap/SummonRules.h @@ -0,0 +1,360 @@ +////////////////////////////////////////////////////////////////////////////////// +// 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 +#include +#include +#include +#include +#include + +// How summons move, act, and hurt, without the map (v83 CSummoned and +// CVecCtrlSummoned, report 158). +namespace ms::summon_rules +{ + constexpr int32_t OCTOPUS = 5211001; + constexpr int32_t GAVIOTA = 5211002; + constexpr int32_t WRATH_OF_THE_OCTOPI = 5220002; + + // How a summon moves: its spawn's moveAbility. + enum MoveAbility : uint8_t + { + STATIONARY = 0, + FOLLOW = 1, + WALK = 2, + CIRCLE_FOLLOW = 3, + CIRCLE_STATIONARY = 4 + }; + + // What it does on its own: its spawn's assistType. + enum Assist : uint8_t + { + NOTHING = 0, + ATTACK = 1, + SUPPORT = 2 + }; + + // Its animations under `summon` (table 0xbec3bc). + enum Action : int8_t + { + NONE = -1, + STAND = 0, + MOVE = 1, + FLY = 2, + SUMMONED = 3, + ATTACK1 = 4, + ATTACK2 = 5, + SKILL1 = 6, + HIT = 12, + DIE = 13 + }; + + inline const char *action_name(int8_t action) + { + static const char *names[] = { + "stand", "move", "fly", "summoned", "attack1", "attack2", "skill1", "skill2", "skill3", "skill4", + "skill5", "skill6", "hit", "die", "say" + }; + + return action >= 0 && action < 15 ? names[action] : "stand"; + } + + // The skills that summon (net::summons::kind on the server). + inline bool is_summon(int32_t skill) + { + switch (skill) + { + case 3111002: case 3211002: case 13111004: case OCTOPUS: case WRATH_OF_THE_OCTOPI: + case 3111005: case 3211005: case 2311006: case 3121006: case 3221005: case GAVIOTA: + case 1321007: case 2121005: case 2221005: case 2321003: case 11001004: case 12001004: + case 13001004: case 14001005: case 15001004: case 12111004: + return true; + default: + return false; + } + } + + // The summons that hit one mob, then spread to more around it, a hit + // every 120 ms. + inline bool spreads(int32_t skill) + { + return skill == 3121006 || skill == 3221005 || skill == GAVIOTA || skill == 11001004 || + skill == 12001004 || skill == 13001004 || skill == 14001005 || skill == 15001004; + } + + // The Puppets: the only summons a mob can hurt, and that a mob turns + // on. + inline bool is_puppet(int32_t skill) + { + return skill == 3111002 || skill == 3211002 || skill == 13111004; + } + + inline bool is_octopus(int32_t skill) + { + return skill == OCTOPUS || skill == WRATH_OF_THE_OCTOPI; + } + + // The Cygnus Knights' souls, which follow closer and spread their hits. + inline bool is_soul(int32_t skill) + { + return skill == 11001004 || skill == 12001004 || skill == 13001004 || skill == 14001005 || + skill == 15001004; + } + + // The mage summons: magic hits, in a box beside them. + inline bool is_magic(int32_t skill) + { + return skill == 2121005 || skill == 2221005 || skill == 2321003 || skill == 12111004 || skill == 2311006 || + skill == 12001004; + } + + inline bool aims_at_box(int32_t skill) + { + return skill == 2121005 || skill == 2221005 || skill == 2321003 || skill == 12111004; + } + + inline bool flies(uint8_t moves) + { + return moves == CIRCLE_FOLLOW || moves == CIRCLE_STATIONARY; + } + + // Whether it attacks or supports at all (FUN_00766612): not a Puppet. + inline bool can_act(uint8_t moves, int32_t skill) + { + return moves != STATIONARY || is_octopus(skill); + } + + // The ms between its attacks (FUN_00765047). + inline int32_t attack_interval(int32_t skill) + { + return skill == WRATH_OF_THE_OCTOPI || skill == GAVIOTA ? 1500 : 3000; + } + + // The animation for a move action (`stance << 1 | left`), unless a + // one-shot plays (FUN_007a3d98). + inline Action pick(int8_t oneshot, uint8_t action) + { + if (oneshot >= 0) + return static_cast(oneshot); + + switch (action >> 1) + { + case 1: + return MOVE; + case 6: + return FLY; + default: + return STAND; + } + } + + // v83 moves a summon every 30 ms physics step. + constexpr double TICK = 30.0; + // How fast a follower glides: 7 px a step. + constexpr double GLIDE = 7.0 / TICK; + // How fast a flier flies, in px per ms (I: the vecctrl's fly speed). + constexpr double FLY_SPEED = 0.2; + + // Whether an owner this far away leaves their summon behind, so it + // moves to them at once. + inline bool lost(double dx, double dy) + { + return std::abs(dx) > 300 || std::abs(dy) > 200; + } + + struct Follower + { + double x; + double y; + bool left; + bool moving = false; + // How long the owner has stood more than 20 px above or below. + double apart = 0; + // Whether it climbs to the owner's height. + bool climbing = false; + }; + + // Glide `ms` ms after the owner, staying `gap` px from them + // (FUN_009c7f9b moveAbility 1). An owner on a ladder or rope holds it. + inline void follow(Follower &summon, double ox, double oy, bool held, double gap, double ms) + { + summon.left = ox <= summon.x; + summon.moving = false; + double step = GLIDE * ms; + + if (!held) + { + if (ox > summon.x + gap) + { + summon.x = std::min(ox - gap, summon.x + step); + summon.moving = true; + } else if (ox < summon.x - gap) + { + summon.x = std::max(ox + gap, summon.x - step); + summon.moving = true; + } + } + + double dy = oy - summon.y; + + if (!summon.climbing) + { + summon.apart = std::abs(dy) > 20 ? summon.apart + ms : 0; + summon.climbing = std::abs(dy) > 100 || summon.apart >= 30 * TICK; + } + + if (summon.climbing) + { + summon.y = dy > 0 ? std::min(oy, summon.y + step) : std::max(oy, summon.y - step); + + if (summon.y == oy) + { + summon.climbing = false; + summon.apart = 0; + } + } + } + + struct Circler + { + double x; + double y; + bool left; + double tx = 0; + double ty = 0; + // The ms until it picks a new spot. + double retarget = 0; + }; + + // Fly `ms` ms around `anchor`: the owner (CIRCLE_FOLLOW, FUN_009c857e), + // or where it was summoned (CIRCLE_STATIONARY), to a spot picked with + // `rand(n)` in [0, n) that it changes every 1.8-3.6 s or on arriving. + inline void circle(Circler &summon, double ax, double ay, bool stationary, const std::function &rand, + double ms) + { + summon.retarget -= ms; + + if (summon.retarget <= 0 || std::hypot(summon.tx - summon.x, summon.ty - summon.y) <= 10) + { + if (stationary) + { + summon.tx = ax + rand(800) - 400; + summon.ty = ay + rand(150) - 150; + } else + { + summon.tx = ax + rand(120) - 60; + summon.ty = ay + rand(60) - 30; + } + + summon.retarget = (rand(60) + 60) * TICK; + } + + double dx = summon.tx - summon.x; + double dy = summon.ty - summon.y; + double distance = std::hypot(dx, dy); + double step = std::min(distance, FLY_SPEED * ms); + + if (distance > 0) + { + summon.x += dx / distance * step; + summon.y += dy / distance * step; + } + + if (dx != 0) + summon.left = dx < 0; + } + + // How far ahead of its owner a summon is put: Puppets 200 px, the + // Octopi 45, the rest where the owner stands. + inline int16_t placement(int32_t skill) + { + if (is_puppet(skill)) + return 200; + + if (is_octopus(skill)) + return 45; + + return 0; + } + + // Where a summon cast from `owner` facing `left` goes (FUN_0096b140): + // ahead within the map's x bounds, onto the first foothold within 80 + // px up or down, trying 15 px nearer the owner each time; or nowhere. + // `ground(x, top)` is the height of the first foothold below (x, top). + inline std::optional> place(int32_t skill, int16_t ox, int16_t oy, bool left, + int16_t xmin, int16_t xmax, + const std::function &ground) + { + int16_t ahead = placement(skill); + + if (ahead == 0) + return std::make_pair(ox, oy); + + int32_t direction = left ? -1 : 1; + int32_t x = std::clamp(ox + direction * ahead, xmin, xmax); + + while ((x - ox) * direction >= 0) + { + int16_t y = ground(static_cast(x), static_cast(oy - 80)); + + if (y <= oy + 80) + return std::make_pair(static_cast(x), static_cast(y - 1)); + + x -= direction * 15; + } + + return std::nullopt; + } + + // One hit of a summon that hits with weapons (FUN_0079216d): 70-100% + // of the main stat times 2.5, plus the others, times the summon's + // `pad` percent. `roll` is in [0, 1). + inline int32_t physical_damage(int32_t main, int32_t others, int32_t pad, double roll) + { + double damage = (main * (0.7 + 0.3 * roll) * 2.5 + others) * pad * 0.01; + + return std::max(1, static_cast(damage)); + } + + // The main stat of a summoner's job: STR for warriors, LUK for + // thieves, DEX for bowmen and pirates, INT for magicians. + enum MainStat + { + STR, + DEX, + INT, + LUK + }; + + inline MainStat main_stat(int16_t job) + { + switch (job / 100 % 10) + { + case 2: + return INT; + case 3: + case 5: + return DEX; + case 4: + return LUK; + default: + return STR; + } + } +} diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index f7f47e43f..b4547ca94 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -44,7 +44,7 @@ namespace ms constexpr int16_t TELEPORT_COOLDOWN = 600; } - Stage::Stage() : combat(player, chars, mobs, reactors, physics, mapinfo) + Stage::Stage() : combat(player, chars, mobs, reactors, summons, physics, mapinfo) { state = State::INACTIVE; } @@ -96,6 +96,7 @@ namespace ms drops.clear(); reactors.clear(); mists.clear(); + summons.clear(); } void Stage::load_map(int32_t mapid) @@ -154,8 +155,11 @@ namespace ms chars.draw(id, viewx, viewy, alpha); player.draw(id, viewx, viewy, alpha); drops.draw(id, viewx, viewy, alpha); + summons.draw(id, viewx, viewy, alpha); } + summons.draw(-1, viewx, viewy, alpha); + const Party &party = player.get_party(); if (party.shows_hp_marks()) @@ -202,6 +206,9 @@ namespace ms if (update_teleport()) drawn = player.get_position(); + // The player's summons follow where the player now is. + summons.update(physics, player.get_position(), player.is_climbing()); + portals.update(player.get_position()); check_touch_portals(drawn); camera.update(player.get_position()); @@ -637,6 +644,11 @@ namespace ms return mists; } + MapSummons &Stage::get_summons() + { + return summons; + } + MapDrops &Stage::get_drops() { return drops; diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index fb0bf2df5..c31d13d30 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -32,6 +32,7 @@ #include "MapleMap/MapChars.h" #include "MapleMap/MapMobs.h" #include "MapleMap/MapMists.h" +#include "MapleMap/MapSummons.h" #include "MapleMap/MapReactors.h" #include "MapleMap/MapNpcs.h" #include "MapleMap/MapDrops.h" @@ -106,6 +107,9 @@ namespace ms // Returns a reference to the mists on the current map. MapMists &get_mists(); + // Returns a reference to the summons on the current map. + MapSummons &get_summons(); + // Returns a reference to the drops on the current map. MapDrops &get_drops(); @@ -212,6 +216,7 @@ namespace ms MapMobs mobs; MapDrops drops; MapMists mists; + MapSummons summons; MapEffect effect; // Effects left where they started, such as a jump pad's burst. EffectLayer ground_effects; diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index ce0fc8f60..9966b82ce 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -22,7 +22,10 @@ #include "Helpers/MobStatParser.h" #include "Helpers/MovementParser.h" +#include "../../Data/SkillData.h" #include "../../Gameplay/Stage.h" +#include "../../IO/UI.h" +#include "../../IO/UITypes/UIChatBar.h" namespace ms { @@ -122,6 +125,7 @@ namespace ms int32_t cid = recv.read_int(); Stage::get().get_chars().remove(cid); + Stage::get().get_summons().remove_owner(cid); } void SpawnPetHandler::handle(InPacket& recv) const @@ -593,6 +597,100 @@ namespace ms Stage::get().get_mists().remove(oid); } + void SpawnSummonHandler::handle(InPacket& recv) const + { + // v83 FUN_0095adec (the player's) and FUN_00938f61 (another + // user's). The foothold is found again here. + int32_t owner = recv.read_int(); + int32_t oid = recv.read_int(); + int32_t skillid = recv.read_int(); + recv.skip_byte(); + uint8_t level = recv.read_byte(); + Point position = recv.read_point(); + uint8_t action = recv.read_byte(); + recv.skip_short(); + uint8_t moves = recv.read_byte(); + uint8_t assist = recv.read_byte(); + bool appear = recv.length() > 0 ? recv.read_byte() == 0 : true; + + Stage::get().get_summons().spawn(owner, oid, skillid, level, position, action, moves, assist, appear, + Stage::get().is_player(owner)); + } + + void RemoveSummonHandler::handle(InPacket& recv) const + { + // v83 FUN_007a64eb. + int32_t owner = recv.read_int(); + int32_t oid = recv.read_int(); + uint8_t reason = recv.read_byte(); + + int32_t skillid = Stage::get().get_summons().remove(oid, reason); + + if (skillid == 0 || !Stage::get().is_player(owner) || (reason != 0 && reason != 3)) + return; + + const std::string &name = SkillData::get(skillid).get_name(); + std::string message = reason == 0 ? name + "'s time has run out, and will disappear." + : name + " is disappearing."; + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(message, UIChatBar::LineType::SKILL); + } + + void MoveSummonHandler::handle(InPacket& recv) const + { + recv.skip_int(); + int32_t oid = recv.read_int(); + Point start = recv.read_point(); + std::vector movements = MovementParser::parse_movements(recv, start); + + Stage::get().get_summons().move(oid, movements); + } + + void SummonAttackHandler::handle(InPacket& recv) const + { + // v83 FUN_007a6882: one hit on each mob. + recv.skip_int(); + int32_t oid = recv.read_int(); + recv.skip_byte(); + uint8_t action = recv.read_byte(); + uint8_t count = recv.read_byte(); + std::vector> hits; + + for (uint8_t i = 0; i < count; i++) + { + int32_t mob = recv.read_int(); + + if (mob == 0) + continue; + + recv.skip_byte(); + hits.emplace_back(mob, recv.read_int()); + } + + Stage::get().get_combat().show_summon_attack(oid, action, hits); + } + + void SummonSkillHandler::handle(InPacket& recv) const + { + recv.skip_int(); + int32_t oid = recv.read_int(); + uint8_t action = recv.read_byte(); + + Stage::get().get_summons().act(oid, action); + } + + void DamageSummonHandler::handle(InPacket& recv) const + { + // v83 FUN_007a6ebe; the mob that hit it, if any, follows. + recv.skip_int(); + int32_t oid = recv.read_int(); + recv.skip_byte(); + int32_t damage = recv.read_int(); + + Stage::get().get_summons().hurt(oid, damage); + } + void RemoveReactorHandler::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 ed4204948..e04e0ac65 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -220,6 +220,48 @@ namespace ms void handle(InPacket& recv) const override; }; + // A user's summon appears + // Opcode: SPAWN_SUMMON(175) + class SpawnSummonHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // A summon goes; the player reads why theirs did + // Opcode: REMOVE_SUMMON(176) + class RemoveSummonHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Another user's summon moves + // Opcode: MOVE_SUMMON(177) + class MoveSummonHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Another user's summon attacks + // Opcode: SUMMON_ATTACK(178) + class SummonAttackHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Another user's Beholder heals or buffs them + // Opcode: SUMMON_SKILL(179) + class SummonSkillHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // A mob hurts another user's Puppet + // Opcode: DAMAGE_SUMMON(180) + class DamageSummonHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Change state of reactor // Opcode: HIT_REACTOR(277) class HitReactorHandler : public PacketHandler diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index cbd69d1ee..cc7c0b2c6 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -119,6 +119,9 @@ namespace ms DENY_PARTY_REQUEST = 125, ADMIN_COMMAND = 128, BUDDYLIST_MODIFY = 130, + MOVE_SUMMON = 175, + SUMMON_ATTACK = 176, + DAMAGE_SUMMON = 177, MOVE_MONSTER = 188, MOB_DISTANCE = 189, NPC_ACTION = 197, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index d530ee6ca..261da2a9a 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -109,6 +109,12 @@ namespace ms /// MapObject SPAWN_PET = 168, CHAR_MOVED = 185, + SPAWN_SUMMON = 175, + REMOVE_SUMMON = 176, + MOVE_SUMMON = 177, + SUMMON_ATTACK = 178, + SUMMON_SKILL = 179, + DAMAGE_SUMMON = 180, /// Attack ATTACKED_CLOSE = 186, @@ -225,6 +231,12 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 364112e80..07d4ce149 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -197,6 +197,68 @@ namespace ms } }; + // Opcode: USE_SKILL(91) + class SummonSkillPacket : public OutPacket + { + public: + // Cast a summon where it should appear, facing as the player + // (FUN_0096b140). Unlike the other skills it ends with no lock. + SummonSkillPacket(int32_t skillid, int32_t level, Point position, bool left) : OutPacket(OutPacket::Opcode::USE_SKILL) + { + write_time(); + write_int(skillid); + write_byte(static_cast(level)); + write_point(position); + write_byte(left); + } + }; + + // Opcode: SUMMON_ATTACK(176) + class SummonAttackPacket : public OutPacket + { + public: + // One hit on a mob. + struct Hit + { + int32_t oid; + int32_t mobid; + Point position; + bool mobleft; + int16_t delay; + int32_t damage; + }; + + // The player's summon attacked with its `action` (0x7a57d4): where + // the player and it are, then each mob it hit once. + SummonAttackPacket(int32_t oid, uint8_t action, bool left, Point owner, Point summon, const std::vector& hits) : OutPacket(OutPacket::Opcode::SUMMON_ATTACK) + { + write_int(oid); + write_time(); + write_byte(static_cast((left ? 0x80 : 0) | (action & 0x7F))); + write_byte(static_cast(hits.size())); + write_point(owner); + write_point(summon); + + for (const Hit& hit : hits) + { + write_int(hit.oid); + write_int(hit.mobid); + // Its hit action, facing, and the damage stat. + write_byte(6); + write_byte(static_cast(hit.mobleft ? 0x80 : 0)); + write_byte(0); + write_byte(0); + write_point(hit.position); + write_point(hit.position); + write_short(hit.delay); + write_int(hit.damage); + } + + // The skill data's checksum. + write_int(0); + } + }; + // Tells the server which mobs Monster Magnet reached: each one's oid, 0 // for one out of sight, and whether it was caught (0x96c62a). It may // reach none. diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index a958ce4be..c14209de4 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -226,6 +226,19 @@ namespace ms } }; + // Opcode: MOVE_SUMMON(175) + class MoveSummonPacket : public MovementPacket + { + public: + // The player's summon moved (FUN_009c84e9): its object id, then a + // path as the player's. + MoveSummonPacket(int32_t oid, const move_path::Path& path) : MovementPacket(OutPacket::Opcode::MOVE_SUMMON) + { + write_int(oid); + writepath(path.start, path.elements); + } + }; + // Shows the player's emote to the rest of the map // Opcode: FACE_EXPRESSION(51) class FaceExpressionPacket : public OutPacket diff --git a/HeavenClient/tests/summon_rules.cpp b/HeavenClient/tests/summon_rules.cpp new file mode 100644 index 000000000..a72e279ce --- /dev/null +++ b/HeavenClient/tests/summon_rules.cpp @@ -0,0 +1,105 @@ +#include "../Gameplay/MapleMap/SummonRules.h" + +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +using namespace ms::summon_rules; + +int main() +{ + try + { + require(is_summon(3111005) && is_summon(1321007) && !is_summon(3111004), "summon skills are known"); + require(spreads(GAVIOTA) && !spreads(3111005), "Gaviota spreads its hits"); + require(!can_act(STATIONARY, 3111002), "a Puppet only stands there"); + require(can_act(STATIONARY, OCTOPUS), "the Octopus stays put but shoots"); + require(attack_interval(3111005) == 3000 && attack_interval(GAVIOTA) == 1500, + "Gaviota and Wrath of the Octopi attack twice as often"); + require(pick(-1, 1 << 1 | 1) == MOVE && pick(-1, 6 << 1) == FLY && pick(-1, 0) == STAND, + "the move action picks move, fly, or stand"); + require(pick(ATTACK1, 1 << 1) == ATTACK1, "a one-shot plays over the move action"); + + // A follower glides 7 px a step until 70 px from its owner. + Follower elquines{ 0, 0, false }; + follow(elquines, 100, 0, false, 70, TICK); + require(elquines.x == 7 && elquines.moving && !elquines.left, "it glides 7 px a step toward the owner"); + + for (int i = 0; i < 10; i++) + follow(elquines, 100, 0, false, 70, TICK); + + require(elquines.x == 30 && !elquines.moving, "it stops 70 px short"); + follow(elquines, 100, 0, true, 70, TICK); + follow(elquines, -100, 0, false, 70, TICK); + require(elquines.left && elquines.x == 23, "it turns to an owner behind it"); + + // It climbs to an owner 30 px up only after 30 steps apart, at once + // to one more than 100 px away. + Follower soul{ 0, 0, false }; + + for (int i = 0; i < 29; i++) + follow(soul, 0, -30, false, 42, TICK); + + require(soul.y == 0, "an owner a little above is waited for"); + follow(soul, 0, -30, false, 42, TICK); + require(soul.y == -7, "after 30 steps it climbs"); + + for (int i = 0; i < 4; i++) + follow(soul, 0, -30, false, 42, TICK); + + require(soul.y == -30 && !soul.climbing, "it stops at the owner's height"); + follow(soul, 0, 90, false, 42, TICK); + require(soul.y == -23, "an owner far below is gone after at once"); + + require(!lost(300, -200) && lost(301, 0) && lost(0, 201), "an owner beyond 300 x 200 px is lost"); + + // A flier picks a spot near the owner and flies there. + int32_t calls = 0; + auto middle = [&](int32_t n) { calls++; return n / 2; }; + Circler hawk{ 0, 0, false }; + circle(hawk, 500, 100, false, middle, TICK); + require(hawk.tx == 500 && hawk.ty == 100 && hawk.retarget == 90 * TICK, "the spot is around the owner"); + require(std::abs(hawk.x - FLY_SPEED * TICK * 500 / std::hypot(500, 100)) < 1e-9 && !hawk.left, + "it flies toward the spot"); + circle(hawk, 500, 100, false, middle, TICK); + require(calls == 3, "it keeps its spot"); + auto low = [](int32_t) { return 0; }; + Circler eagle{ 0, 0, false }; + circle(eagle, 0, 0, true, low, TICK); + require(eagle.tx == -400 && eagle.ty == -150 && eagle.left, "a stationary flier ranges over its spawn"); + + // Puppets go 200 px ahead onto a foothold within 80 px, else nearer. + auto flat = [](int16_t, int16_t) -> int16_t { return 100; }; + auto placed = place(3111002, 0, 100, false, -1000, 1000, flat); + require(placed && placed->first == 200 && placed->second == 99, "a Puppet stands 200 px ahead"); + placed = place(3111002, 0, 100, true, -150, 1000, flat); + require(placed && placed->first == -150, "it stays inside the map"); + auto ledge = [](int16_t x, int16_t) -> int16_t { return x < 50 ? 100 : 500; }; + placed = place(3111002, 0, 100, false, -1000, 1000, ledge); + require(placed && placed->first == 35, "past a ledge it steps back 15 px at a time"); + auto pit = [](int16_t, int16_t) -> int16_t { return 900; }; + require(!place(3111002, 0, 100, false, -1000, 1000, pit), "with no foothold it isn't cast"); + placed = place(3111005, 10, 20, true, -1000, 1000, pit); + require(placed && placed->first == 10 && placed->second == 20, "a hawk starts at its owner"); + placed = place(OCTOPUS, 0, 100, true, -1000, 1000, flat); + require(placed && placed->first == -45, "the Octopus sits 45 px ahead"); + + require(physical_damage(100, 40, 100, 0.0) == 215 && physical_damage(100, 40, 100, 0.999999) == 289, + "a hit is 70-100% of the main stat times 2.5, plus the others"); + require(physical_damage(100, 40, 50, 0.0) == 107, "times the summon's pad percent"); + require(main_stat(311) == DEX && main_stat(412) == LUK && main_stat(212) == INT && main_stat(1100) == STR && + main_stat(520) == DEX, "each job has its main stat"); + + std::cout << "Summon rules passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 76e016335..99566a00a 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -395,6 +395,81 @@ v83 does (`MistLayout.h`, a diamond of random tiles), draws them above the map's objects, and fades them out over a second when the mist lifts. `v83ref` report 160 has the client's detail. +## Summons + +Puppet, Silver Hawk, the Octopus, Elquines, the Knights' souls and the like +call up a summon (`net::summons`, the field's `field_summons::SummonSet`). +USE_SKILL for a skill in `summons::kind` carries where the caster's client +put it and which way it faces after the level. The client puts Puppets 200 +px ahead on a foothold within 80 px, the Octopi 45 px ahead, and the rest at +the owner (v83 `FUN_0096b140`). The cast costs MP and any `itemCon`, for +example a Summoning Rock for Silver Hawk. It also gives a buff that shows +the summon's icon and lasts its `time`: Puppet's own stat for summons that +stay put, the combo counter for the rest. The field then takes the summon +(`FieldRequest::Summon`). If the spot is more than 250×150 px from the +owner, it uses the owner's position. The owner's earlier summon of that +kind is dismissed. SPAWN_SUMMON (`0xAF`) shows it to the map and to players +who join. + +The owner's client moves it and decides when it attacks. The server checks +the rest: + +- **MOVE_SUMMON** (`0xAF` in, `0xB1` out) is relayed to the others. +- **SUMMON_ATTACK** (`0xB0` in, `0xB2` out) is refused when any of these + holds: + - it hits more mobs than the skill's `mobCount`; + - it hits one mob twice; + - a hit exceeds `summons::max_damage`, which is twice what the owner's + client can roll before multipliers, so it is the owner's main stat × + 2.5 plus their other stats for a physical summon, or the v83 spell + formula for a magic one, times the level's `pad` or `mad` percent. + + Otherwise the hits go through the field's usual damage and kill path. +- **DAMAGE_SUMMON** (`0xB1` in, `0xB4` out) hurts a Puppet. Puppets are the + only summons that can be hurt, and their HP is the level's `x`. At 0 HP + the Puppet breaks: REMOVE_SUMMON reason 2, and the owner's buff ends. + +REMOVE_SUMMON (`0xB0`) reasons: + +- 0: the summon expired when its buff did. +- 1: silently, for example when the owner rejoins the same map. +- 2: a Puppet broke. +- 4: it was replaced. + +When the owner changes maps, a summon that follows comes along: it spawns +again with no entrance. Stationary summons end there. The others drop a +player's summons when the player leaves, with no packet. + +HeavenClient's `MapSummons` (`Gameplay/MapleMap`) draws them and plays their +`summon` animations. `SummonRules.h` holds v83's rules without the map: + +- Followers glide 7 px a step to 70 px from the owner (42 for the souls). +- Fliers circle a random spot near the owner, or near their spawn if they + stay put. +- Any summon jumps to an owner more than 300×200 px away. + +`Combat::update_summons` makes the owner's summons attack every 3 s +(Gaviota and Wrath every 1.5 s): + +- The mage summons hit the side of their `lt`/`rb` box with more mobs. +- The others hit the nearest mobs within their `range/r`. +- The spreading kinds hit one mob and then up to `mobCount`−1 more around + it, 120 ms apart. +- Each hit lands `attackAfter` plus the projectile's flight time after the + attack starts. + +Where HeavenClient differs from v83: + +- Ball sprites aren't drawn. +- Followers send their paths the way fliers do. +- The fly speed (0.2 px/ms) is inferred. +- Range targeting is a ±`r` × ±100 px box. +- Puppets don't draw mobs to them yet, and mobs don't hurt summons + client-side yet. +- Beholder's buffs and heals (`0xB3`) are missing. + +`v83ref` report 158 has the client's detail. + ## Reactors A map's `reactor` nodes (`game_data::MapReactorTemplate`) place reactors such @@ -486,4 +561,7 @@ Three Snails, Nimble Feet, and Recovery and checks their damage caps, MP, buffs, cooldowns, Recovery's heal, what the others see, and that the buffs end on time and on death. `poison_mist` casts Poison Mist on a snail and checks the cloud's packet, that it lifts on time, and that it poisons only -the snails in its box. +the snails in its box. `summons` has a ranger call up a Silver Hawk and a +Puppet, and checks what a watcher sees: the spawns, a refused and an accepted +Hawk attack, the Puppet breaking, and the Hawk following its owner to the +next map with no entrance. diff --git a/game/src/buff.rs b/game/src/buff.rs index 6aed0ce5e..656e94eee 100644 --- a/game/src/buff.rs +++ b/game/src/buff.rs @@ -44,6 +44,9 @@ pub enum BuffStat { SharpEyes = 37, Infinity = 41, Concentrate = 45, + /// A summon that stays where it was put: a Puppet or an Octopus. The + /// client only shows its icon (Cosmic's PUPPET). + Puppet = 46, } impl BuffStat { diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index cfd40a388..7bd8a36b6 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -154,6 +154,47 @@ pub fn use_skill(skill_id: i32, level: u8) -> Packet { p } +/// USE_SKILL of a summon skill, putting it at (x, y) facing left or not. +pub fn summon(skill_id: i32, level: u8, x: i16, y: i16, left: bool) -> Packet { + let mut p = use_skill(skill_id, level); + p.write_short(x).unwrap(); + p.write_short(y).unwrap(); + p.write_byte(u8::from(left)).unwrap(); + p +} + +/// SUMMON_ATTACK: summon `object_id` hitting mob `mob` once, as HeavenClient +/// sends it (0x7a57d4). +pub fn summon_attack(object_id: i32, mob: i32, mob_id: i32, damage: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::SummonAttack as i16).unwrap(); + p.write_int(object_id).unwrap(); + p.write_int(0).unwrap(); + p.write_byte(0x84).unwrap(); + p.write_byte(1).unwrap(); + p.write_bytes(&[0; 8]).unwrap(); + p.write_int(mob).unwrap(); + p.write_int(mob_id).unwrap(); + p.write_bytes(&[6, 0x80, 0, 0]).unwrap(); + p.write_bytes(&[0; 8]).unwrap(); + p.write_short(100).unwrap(); + p.write_int(damage).unwrap(); + p.write_int(0).unwrap(); + p +} + +/// DAMAGE_SUMMON: a mob's touch (attack -1) hurting summon `object_id`. +pub fn damage_summon(object_id: i32, damage: i32, mob_id: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::DamageSummon as i16).unwrap(); + p.write_int(object_id).unwrap(); + p.write_byte(0xFF).unwrap(); + p.write_int(damage).unwrap(); + p.write_int(mob_id).unwrap(); + p.write_byte(0).unwrap(); + p +} + /// CANCEL_BUFF: right-clicking a skill's buff icon. pub fn cancel_buff(skill_id: i32) -> Packet { let mut p = Packet::new_empty(); @@ -238,6 +279,27 @@ pub struct Field { pub mists: Vec, /// Object ids of the mists removed. pub mists_removed: Vec, + pub summons: Vec, + /// (object id, why it went) + pub summons_removed: Vec<(i32, u8)>, + /// Summon paths relayed from their owners: object ids. + pub summons_moved: Vec, + /// (object id, action, (mob object id, damage) per hit) + pub summon_attacks: Vec<(i32, u8, Vec<(i32, i32)>)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MapSummon { + pub owner_id: i32, + pub object_id: i32, + pub skill_id: i32, + pub level: u8, + pub x: i16, + pub y: i16, + pub moves: u8, + pub assist: u8, + /// Whether it plays its `summoned` animation. + pub appears: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -301,6 +363,48 @@ impl Field { }); } else if op == SendOpcode::RemoveMist as i16 { self.mists_removed.push(r.read_int().unwrap()); + } else if op == SendOpcode::SpawnSummon as i16 { + let owner_id = r.read_int().unwrap(); + let object_id = r.read_int().unwrap(); + let skill_id = r.read_int().unwrap(); + r.read_byte().unwrap(); + let level = r.read_byte().unwrap(); + let (x, y) = (r.read_short().unwrap(), r.read_short().unwrap()); + r.read_byte().unwrap(); + r.read_short().unwrap(); + self.summons.push(MapSummon { + owner_id, + object_id, + skill_id, + level, + x, + y, + moves: r.read_byte().unwrap(), + assist: r.read_byte().unwrap(), + appears: r.read_byte().unwrap() == 0, + }); + } else if op == SendOpcode::RemoveSummon as i16 { + r.read_int().unwrap(); + let object_id = r.read_int().unwrap(); + self.summons_removed + .push((object_id, r.read_byte().unwrap())); + } else if op == SendOpcode::MoveSummon as i16 { + r.read_int().unwrap(); + self.summons_moved.push(r.read_int().unwrap()); + } else if op == SendOpcode::SummonAttack as i16 { + r.read_int().unwrap(); + let object_id = r.read_int().unwrap(); + r.read_byte().unwrap(); + let action = r.read_byte().unwrap(); + let count = r.read_byte().unwrap(); + let hits = (0..count) + .map(|_| { + let mob = r.read_int().unwrap(); + r.read_byte().unwrap(); + (mob, r.read_int().unwrap()) + }) + .collect(); + self.summon_attacks.push((object_id, action, hits)); } else if op == SendOpcode::SpawnMonsterControl as i16 { let mode = r.read_byte().unwrap(); let oid = r.read_int().unwrap(); diff --git a/integration-harness/tests/summons.rs b/integration-harness/tests/summons.rs new file mode 100644 index 000000000..8bf4f7b7b --- /dev/null +++ b/integration-harness/tests/summons.rs @@ -0,0 +1,126 @@ +//! Summons on Snail Hunting Ground II (40001): a Ranger's Silver Hawk +//! and Puppet appear for the map, the Hawk's attacks reach the others +//! within its damage cap, a Puppet breaks when hurt past its HP, and the +//! Hawk follows its owner to the next map. + +use integration_harness::database::{grant_skill, grant_stack, set_job}; +use integration_harness::hunting::{attack, damage_summon, summon, summon_attack, walk_to}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read_for, send, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use tokio::time::Duration; + +const HUNTING_GROUND: i32 = 40_001; +const FOREST: i32 = 40_000; +const SNAIL: i32 = 100100; +const RANGER: i16 = 311; +const SILVER_HAWK: i32 = 3111005; +const PUPPET: i32 = 3111002; +const ETC: i16 = 4; +const SUMMONING_ROCK: i32 = 4006001; + +#[tokio::test] +async fn a_rangers_hawk_attacks_and_follows_and_their_puppet_breaks() { + let config = load_harness_config_or_fail().await; + let ranger_id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(ranger_id, RANGER, 70, 2000) + .await + .expect("make a ranger"); + grant_skill(ranger_id, SILVER_HAWK, 1) + .await + .expect("teach Silver Hawk"); + // Level 1: 5 seconds, and 500 HP. + grant_skill(ranger_id, PUPPET, 1) + .await + .expect("teach Puppet"); + // Each Hawk costs a Summoning Rock. + grant_stack(ranger_id, ETC, 1, SUMMONING_ROCK, 5) + .await + .expect("give Summoning Rocks"); + + let mut ranger = relogin(&config).await; + let watcher_config = load_harness_config_or_fail().await; + let mut watcher = login_to_world_session(&watcher_config).await.unwrap(); + let (r, w) = (&mut ranger.connection, &mut watcher.connection); + let (mut rt, mut wt) = (Trace::default(), Trace::default()); + assert_eq!(warp(w, HUNTING_GROUND, "", &mut wt).await, HUNTING_GROUND); + assert_eq!(warp(r, HUNTING_GROUND, "", &mut rt).await, HUNTING_GROUND); + until(r, &mut rt, |t| !t.field.alive(SNAIL).is_empty()).await; + let snail = rt.field.alive(SNAIL)[0]; + let (x, y) = rt.field.mob_positions[&snail]; + send(r, walk_to(x, y)).await; + + // The Hawk circles its owner and attacks; both see it come. + send(r, summon(SILVER_HAWK, 1, x, y, true)).await; + until(w, &mut wt, |t| !t.field.summons.is_empty()).await; + until(r, &mut rt, |t| !t.field.summons.is_empty()).await; + let hawk = wt.field.summons[0]; + assert_eq!(rt.field.summons[0], hawk); + assert_eq!( + ( + hawk.owner_id, + hawk.skill_id, + hawk.level, + (hawk.x, hawk.y), + hawk.moves, + hawk.assist, + hawk.appears + ), + (ranger_id, SILVER_HAWK, 1, (x, y), 3, 1, true) + ); + + // An attack past what the ranger could roll counts for nothing; one + // within it reaches the watcher. + send(r, summon_attack(hawk.object_id, snail, SNAIL, 100_000)).await; + send(r, summon_attack(hawk.object_id, snail, SNAIL, 5)).await; + until(w, &mut wt, |t| !t.field.summon_attacks.is_empty()).await; + read_for(w, &mut wt, 300).await; + assert_eq!( + wt.field.summon_attacks, + vec![(hawk.object_id, 0x84, vec![(snail, 5)])] + ); + // Leave no hurt snail for the tests that hunt here next. + send(r, attack(snail, 8)).await; + + // The Puppet stands where the ranger's client put it, 200 px ahead. + send(r, summon(PUPPET, 1, x + 200, y, false)).await; + until(w, &mut wt, |t| t.field.summons.len() == 2).await; + let puppet = wt.field.summons[1]; + assert_eq!( + ( + puppet.skill_id, + (puppet.x, puppet.y), + puppet.moves, + puppet.assist + ), + (PUPPET, (x + 200, y), 0, 0) + ); + + // A snail hurts it, then breaks it: everyone sees it go, broken. + send(r, damage_summon(puppet.object_id, 300, SNAIL)).await; + read_for(w, &mut wt, 300).await; + assert!(wt.field.summons_removed.is_empty()); + send(r, damage_summon(puppet.object_id, 200, SNAIL)).await; + until(w, &mut wt, |t| !t.field.summons_removed.is_empty()).await; + until(r, &mut rt, |t| !t.field.summons_removed.is_empty()).await; + assert_eq!(wt.field.summons_removed, vec![(puppet.object_id, 2)]); + assert_eq!(rt.field.summons_removed, vec![(puppet.object_id, 2)]); + + // The Hawk leaves with its owner, unannounced: the others drop it with + // them. It comes along without its entrance. + rt = Trace::default(); + assert_eq!(warp(r, FOREST, "", &mut rt).await, FOREST); + until(r, &mut rt, |t| !t.field.summons.is_empty()).await; + read_for(w, &mut wt, 300).await; + assert_eq!(wt.field.summons_removed.len(), 1); + let again = rt.field.summons[0]; + assert_eq!( + (again.owner_id, again.skill_id, again.appears), + (ranger_id, SILVER_HAWK, false) + ); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 9ef9cb866..555ca2134 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -414,6 +414,36 @@ pub enum FieldRequest { giver_name: String, up: bool, }, + /// The player called up their skill's summon at `at`, facing left or + /// not, for `lasts`, replacing the one their buff of that kind gave. + /// With `appear` it plays its `summoned` animation, but not when it + /// follows them to a new map. + Summon { + skill_id: i32, + level: i32, + at: (i16, i16), + left: bool, + lasts: std::time::Duration, + appear: bool, + }, + /// Send away the player's summon of this skill, or all of them. + Unsummon { + skill_id: Option, + why: crate::packet::build::world::summon::Gone, + }, + /// The player's client moved their summon. + SummonMove { + object_id: i32, + start: (i16, i16), + movement: Vec, + }, + /// The player's summon attacked; it must keep to its skill's limits. + SummonAttack { + attack: crate::summons::SummonAttack, + limits: Vec, + }, + /// A mob hurt the player's Puppet. + SummonDamage(crate::summons::SummonDamage), } impl ReplyAction for WorldAction { @@ -521,6 +551,9 @@ fn world_routes() -> &'static HashMap { (Op::MesoDrop, InGame(&world::MesoDropHandler)), (Op::CharInfoRequest, InGame(&world::CharInfoRequestHandler)), (Op::GiveFame, InGame(&world::GiveFameHandler)), + (Op::MoveSummon, InGame(&world::MoveSummonHandler)), + (Op::SummonAttack, InGame(&world::SummonAttackHandler)), + (Op::DamageSummon, InGame(&world::DamageSummonHandler)), ]; table .into_iter() @@ -551,7 +584,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 49); + assert_eq!(world_routes().len(), 52); } #[test] diff --git a/net/src/lib.rs b/net/src/lib.rs index 2c5484e1c..f126f682f 100644 --- a/net/src/lib.rs +++ b/net/src/lib.rs @@ -29,6 +29,7 @@ pub mod scripts; pub mod settings; pub mod shop; pub mod skills; +pub mod summons; pub use self::game_data::{closest_spawn_point, get as get_game_data, spawn_position}; pub use self::handler::{ diff --git a/net/src/packet/build/world/mod.rs b/net/src/packet/build/world/mod.rs index a4e2a0d19..79843ed86 100644 --- a/net/src/packet/build/world/mod.rs +++ b/net/src/packet/build/world/mod.rs @@ -23,3 +23,4 @@ pub mod reactor; pub mod shop; pub mod skill; pub mod stat; +pub mod summon; diff --git a/net/src/packet/build/world/summon.rs b/net/src/packet/build/world/summon.rs new file mode 100644 index 000000000..7433935b6 --- /dev/null +++ b/net/src/packet/build/world/summon.rs @@ -0,0 +1,217 @@ +//! Summons (v83 CSummoned, report 158). Every packet starts with the +//! owner's character id: the client keeps a user's summons with the user. + +use crate::summons::{Assist, Moves}; +use crate::{error::NetworkError, packet::op::SendOpcode}; +use packet::{io::write::PktWrite, Packet}; + +/// A summon as players see it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SummonView { + pub owner_id: i32, + pub object_id: i32, + pub skill_id: i32, + pub owner_level: u8, + pub level: u8, + pub x: i16, + pub y: i16, + /// The move action: its stance times two, plus one facing left. + pub stance: u8, + pub moves: Moves, + pub assist: Assist, +} + +/// Why a summon went away (REMOVE_SUMMON's last byte, FUN_007a64eb). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Gone { + /// Its time ran out: it fades, and its owner reads "...'s time has + /// run out, and will disappear." + Expired = 0, + /// Without a trace, e.g. its owner left. + Silently = 1, + /// A Puppet was broken: it plays `die`. + Broken = 2, + /// Cancelled or replaced: it plays `die`. + Dismissed = 4, +} + +/// SPAWN_SUMMON (0xAF): with `appear`, it plays `summoned` first. +pub fn build_spawn_summon(summon: &SummonView, appear: bool) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::SpawnSummon as i16)?; + p.write_int(summon.owner_id)?; + p.write_int(summon.object_id)?; + p.write_int(summon.skill_id)?; + p.write_byte(summon.owner_level)?; + p.write_byte(summon.level)?; + p.write_short(summon.x)?; + p.write_short(summon.y)?; + p.write_byte(summon.stance)?; + p.write_short(0)?; // foothold: the client finds it + p.write_byte(summon.moves as u8)?; + p.write_byte(summon.assist as u8)?; + p.write_byte(u8::from(!appear))?; + Ok(p) +} + +/// REMOVE_SUMMON (0xB0). +pub fn build_remove_summon( + owner_id: i32, + object_id: i32, + why: Gone, +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::RemoveSummon as i16)?; + p.write_int(owner_id)?; + p.write_int(object_id)?; + p.write_byte(why as u8)?; + Ok(p) +} + +/// MOVE_SUMMON (0xB1): the path its owner's client sent, from `start`. +pub fn build_move_summon( + owner_id: i32, + object_id: i32, + start: (i16, i16), + movement: &[u8], +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::MoveSummon as i16)?; + p.write_int(owner_id)?; + p.write_int(object_id)?; + p.write_short(start.0)?; + p.write_short(start.1)?; + p.write_bytes(movement)?; + Ok(p) +} + +/// SUMMON_ATTACK (0xB2, FUN_007a6882): the animation (with 0x80 facing +/// left), and each mob it hit with its hit action and the damage. +pub fn build_summon_attack( + summon: &SummonView, + action: u8, + hits: &[crate::summons::SummonHit], +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::SummonAttack as i16)?; + p.write_int(summon.owner_id)?; + p.write_int(summon.object_id)?; + p.write_byte(summon.owner_level)?; + p.write_byte(action)?; + p.write_byte(hits.len() as u8)?; + for hit in hits { + p.write_int(hit.object_id)?; + p.write_byte(hit.action)?; + p.write_int(hit.damage)?; + } + Ok(p) +} + +/// DAMAGE_SUMMON (0xB4, FUN_007a6ebe): as its owner's client sent it. +pub fn build_damage_summon( + owner_id: i32, + damage: &crate::summons::SummonDamage, +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::DamageSummon as i16)?; + p.write_int(owner_id)?; + p.write_int(damage.object_id)?; + p.write_byte(damage.attack as u8)?; + p.write_int(damage.damage)?; + if let Some((mob_id, left)) = damage.mob { + p.write_int(mob_id)?; + p.write_byte(u8::from(left))?; + } + Ok(p) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::summons::SummonHit; + + fn hawk() -> SummonView { + SummonView { + owner_id: 7, + object_id: 900_000_000, + skill_id: 3111005, + owner_level: 70, + level: 30, + x: -100, + y: 200, + stance: 1, + moves: Moves::CircleFollow, + assist: Assist::Attack, + } + } + + fn ints(values: &[i32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() + } + + #[test] + fn a_summon_spawns_with_how_it_moves_and_acts() { + let p = build_spawn_summon(&hawk(), true).unwrap(); + let mut want = vec![0xAF, 0x00]; + want.extend(ints(&[7, 900_000_000, 3111005])); + want.extend([70, 30]); + want.extend((-100i16).to_le_bytes()); + want.extend(200i16.to_le_bytes()); + want.extend([1, 0, 0, 3, 1, 0]); + assert_eq!(p.bytes, want); + assert_eq!( + *build_spawn_summon(&hawk(), false) + .unwrap() + .bytes + .last() + .unwrap(), + 1 + ); + } + + #[test] + fn removals_moves_attacks_and_damage_name_the_owner_and_summon() { + let mut want = vec![0xB0, 0x00]; + want.extend(ints(&[7, 900_000_000])); + want.push(2); + assert_eq!( + build_remove_summon(7, 900_000_000, Gone::Broken) + .unwrap() + .bytes, + want + ); + + let p = build_move_summon(7, 900_000_000, (1, 2), &[1, 9]).unwrap(); + assert_eq!(&p.bytes[..2], [0xB1, 0x00]); + assert_eq!(&p.bytes[10..], [1, 0, 2, 0, 1, 9]); + + let hit = SummonHit { + object_id: 2_000_000_001, + action: 6, + damage: 500, + }; + let p = build_summon_attack(&hawk(), 0x84, &[hit]).unwrap(); + let mut want = vec![0xB2, 0x00]; + want.extend(ints(&[7, 900_000_000])); + want.extend([70, 0x84, 1]); + want.extend(ints(&[2_000_000_001])); + want.push(6); + want.extend(ints(&[500])); + assert_eq!(p.bytes, want); + + let touched = crate::summons::SummonDamage { + object_id: 900_000_000, + attack: -1, + damage: 30, + mob: Some((100100, false)), + }; + let p = build_damage_summon(7, &touched).unwrap(); + let mut want = vec![0xB4, 0x00]; + want.extend(ints(&[7, 900_000_000])); + want.push(0xFF); + want.extend(ints(&[30, 100100])); + want.push(0); + assert_eq!(p.bytes, want); + } +} diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 863dad63f..e97ab7fa9 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -26,6 +26,7 @@ mod reactor; mod rearrange; mod script_portal; mod spend_points; +mod summon; mod use_item; mod use_skill; mod whisper; @@ -60,6 +61,7 @@ pub use self::reactor::DamageReactorHandler; pub use self::rearrange::{GatherItemsHandler, SortItemsHandler}; pub use self::script_portal::ChangeMapSpecialHandler; pub use self::spend_points::{SpendApHandler, SpendSpHandler}; +pub use self::summon::{DamageSummonHandler, MoveSummonHandler, SummonAttackHandler}; pub use self::use_item::{HealOverTimeHandler, UseItemHandler, UseSkillBookHandler}; pub use self::use_skill::{ party_skill_reached, CancelBuffHandler, SkillEffectHandler, ThrowGrenadeHandler, diff --git a/net/src/packet/handle/world/summon.rs b/net/src/packet/handle/world/summon.rs new file mode 100644 index 000000000..a36b8cd62 --- /dev/null +++ b/net/src/packet/handle/world/summon.rs @@ -0,0 +1,75 @@ +//! What the owner's client sends about their summons: it moves them, +//! attacks with them, and reports a Puppet hurt. The field checks the +//! summon is theirs. + +use crate::handler::{ + FieldRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, +}; +use crate::summons; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; +use tracing::debug; + +/// MOVE_SUMMON (0xAF, FUN_009c84e9): the summon's object id, then a path +/// as MOVE_PLAYER's: where it starts, then its elements. +pub struct MoveSummonHandler; + +impl WorldHandler for MoveSummonHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let object_id = r.read_int()?; + let start = (r.read_short()?, r.read_short()?); + let movement = packet.bytes[r.position() as usize..].to_vec(); + if movement.first().is_none_or(|&count| count == 0) { + return Ok(HandlerResult::empty()); + } + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::SummonMove { + object_id, + start, + movement, + })), + ) + } +} + +/// SUMMON_ATTACK (0xB0): one hit on each mob, held to the limits of the +/// summons the player has up. +pub struct SummonAttackHandler; + +impl WorldHandler for SummonAttackHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + let attack = summons::parse_attack(packet)?; + if ctx.player.stats.hp <= 0 { + return Ok(HandlerResult::empty()); + } + let limits = summons::limits(ctx.player); + if limits.is_empty() { + debug!( + object_id = attack.object_id, + "Summon attack without a summon" + ); + return Ok(HandlerResult::empty()); + } + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::SummonAttack { + attack, + limits, + })), + ) + } +} + +/// DAMAGE_SUMMON (0xB1): a mob hurt the player's Puppet. +pub struct DamageSummonHandler; + +impl WorldHandler for DamageSummonHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let damage = summons::parse_damage(packet)?; + if damage.damage < 0 { + return Ok(HandlerResult::empty()); + } + Ok(HandlerResult::empty().with(WorldAction::Field(FieldRequest::SummonDamage(damage)))) + } +} diff --git a/net/src/packet/handle/world/use_skill.rs b/net/src/packet/handle/world/use_skill.rs index 3f37915b0..f28c107e1 100644 --- a/net/src/packet/handle/world/use_skill.rs +++ b/net/src/packet/handle/world/use_skill.rs @@ -3,8 +3,10 @@ use crate::handler::{ FieldRequest, HandlerResult, PartySkill, WorldAction, WorldContext, WorldHandler, WorldResult, }; use crate::packet::build::world::stat::{self, StatUpdate}; +use crate::packet::build::world::summon::Gone; use crate::packet::build::world::{buff, inventory, skill}; use crate::skills::{self, Inflict, Learned}; +use crate::summons; use game::{Buff, BuffStat, InventoryChange, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; @@ -12,10 +14,10 @@ use std::time::{Duration, Instant}; 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. A skill that reaches the party -/// gives its buffs, or Heal its HP, to the members the client found around -/// the player too. +/// `skills::buffs`), the summons, Heal, and Monster Magnet. Every answer +/// unlocks the client, which waits on it 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 { @@ -55,6 +57,30 @@ impl WorldHandler for UseSkillHandler { } }; } + if summons::kind(skill_id).is_some() { + // Where the client put it (FUN_0096b140) and which way it faces. + let at = (r.read_short()?, r.read_short()?); + let left = r.read_byte()? != 0; + return match use_skill(ctx.player, skill_id, level, Instant::now()) { + Ok(used) => { + let lasts = Duration::from_secs(used.skill.effect.duration.max(0) as u64); + Ok(announce(ctx.player, &used)?.with(WorldAction::Field( + FieldRequest::Summon { + skill_id, + level, + at, + left, + lasts, + appear: true, + }, + ))) + } + Err(refusal) => { + debug!(skill_id, level, ?refusal, "Summon refused"); + Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)) + } + }; + } let extras = read_extras(&mut r, skill_id); match use_skill(ctx.player, skill_id, level, Instant::now()) { Ok(used) => { @@ -158,7 +184,13 @@ impl WorldHandler for CancelBuffHandler { if ended.is_empty() { return Ok(HandlerResult::empty()); } - let result = HandlerResult::reply(buff::build_cancel_buff(&ended)?); + let mut result = HandlerResult::reply(buff::build_cancel_buff(&ended)?); + if summons::kind(skill_id).is_some() { + result = result.with(WorldAction::Field(FieldRequest::Unsummon { + skill_id: Some(skill_id), + why: Gone::Dismissed, + })); + } let unseen: Vec<_> = ended.into_iter().filter(|stat| stat.shown()).collect(); if unseen.is_empty() { return Ok(result); diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index bd8202c1b..3174b2476 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -68,6 +68,9 @@ pub enum RecvOpcode { ThrowGrenade = 0x6D, ChangeKeybinds = 0x87, + MoveSummon = 0xAF, + SummonAttack = 0xB0, + DamageSummon = 0xB1, MoveLife = 0xBC, MobDistance = 0xBD, NpcAction = 0xC5, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 09b6fd3bd..fcb901a24 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -43,6 +43,11 @@ pub enum SendOpcode { SpawnPlayer = 0xA0, RemovePlayerFromMap = 0xA1, ChatText = 0xA2, + SpawnSummon = 0xAF, + RemoveSummon = 0xB0, + MoveSummon = 0xB1, + SummonAttack = 0xB2, + DamageSummon = 0xB4, ShowItemGainInChat = 0xCE, UpdateQuestInfo = 0xD3, Cooldown = 0xEA, diff --git a/net/src/skills.rs b/net/src/skills.rs index 54b783602..3de37ed1b 100644 --- a/net/src/skills.rs +++ b/net/src/skills.rs @@ -592,10 +592,14 @@ pub fn prepares(skill_id: i32) -> bool { } /// 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, +/// isn't one. A summon's buff is its icon (`summons::stat`). 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> { + // A summon's `pad` is its own attack, not the player's. + if crate::summons::kind(skill.skill_id).is_some() { + return Some(vec![(crate::summons::stat(skill.skill_id), 1)]); + } let effect = &skill.effect; let own = match skill.skill_id { 1001 | 10001001 | 20001001 => vec![(BuffStat::Recovery, effect.x)], diff --git a/net/src/summons.rs b/net/src/summons.rs new file mode 100644 index 000000000..40748ae6e --- /dev/null +++ b/net/src/summons.rs @@ -0,0 +1,355 @@ +//! Summons: what Puppet, Silver Hawk, the Octopus, Elquines, and the like +//! call up. A summon lasts as long as its skill's buff; the owner's client +//! moves it and decides when it attacks (v83 CSummoned, report 158), and +//! the server shows the others and deals the damage. + +use crate::error::NetworkError; +use crate::skills::{self, Learned}; +use game::{BuffStat, InventoryType, Player}; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +pub const PUPPETS: [i32; 3] = [3111002, 3211002, 13111004]; +pub const OCTOPUS: i32 = 5211001; +pub const GAVIOTA: i32 = 5211002; +pub const WRATH_OF_THE_OCTOPI: i32 = 5220002; +pub const BEHOLDER: i32 = 1321007; + +/// How a summon moves (its spawn's moveAbility, v83 CVecCtrlSummoned). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Moves { + /// Never moves; also follows nobody to another map. + Stationary = 0, + /// Glides after its owner. + Follow = 1, + /// Flies around its owner. + CircleFollow = 3, +} + +/// What a summon does on its own (its spawn's assistType). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Assist { + /// Only stands there (Puppets). + Nothing = 0, + Attack = 1, + /// Heals and buffs its owner (Beholder). + Support = 2, +} + +/// How a skill's summon moves and what it does, or `None` for a skill +/// that summons nothing (the OdinMS-family tables; v83 reads both from the +/// spawn). Beginners' Echo-like 10001013/20001013 summon nothing here. +pub fn kind(skill_id: i32) -> Option<(Moves, Assist)> { + let kind = match skill_id { + 3111002 | 3211002 | 13111004 => (Moves::Stationary, Assist::Nothing), + OCTOPUS | WRATH_OF_THE_OCTOPI => (Moves::Stationary, Assist::Attack), + // Silver Hawk, Golden Eagle, Summon Dragon, Phoenix, Frostprey, + // Gaviota. + 3111005 | 3211005 | 2311006 | 3121006 | 3221005 | GAVIOTA => { + (Moves::CircleFollow, Assist::Attack) + } + BEHOLDER => (Moves::Follow, Assist::Support), + // Elquines, Ifrit, Bahamut, the Cygnus Knights' souls, and Blaze + // Wizard's Ifrit. + 2121005 | 2221005 | 2321003 | 11001004 | 12001004 | 13001004 | 14001005 | 15001004 + | 12111004 => (Moves::Follow, Assist::Attack), + _ => return None, + }; + Some(kind) +} + +pub fn stationary(skill_id: i32) -> bool { + matches!(kind(skill_id), Some((Moves::Stationary, _))) +} + +/// The buff a summon's skill gives, its icon: Puppet's own stat for the +/// summons that stay put, the combo counter at no orbs for the others +/// (as OdinMS-family servers do). A Ranger can keep a Puppet and a Silver +/// Hawk at once, each with its own stat. +pub fn stat(skill_id: i32) -> BuffStat { + if stationary(skill_id) { + BuffStat::Puppet + } else { + BuffStat::Combo + } +} + +/// How much damage a Puppet takes before it breaks: its skill's `x`. The +/// others can't be hurt (FUN_007a6040). +pub fn hp(skill: &Learned) -> Option { + PUPPETS.contains(&skill.skill_id).then_some(skill.effect.x) +} + +/// The most mobs one attack of a summon reaches: its `summon/attack1/info` +/// mobCount (1 if none), or the level's if more (Bahamut's grows). +pub fn mob_count(skill: &Learned) -> i32 { + let info = match skill.skill_id { + 3121006 | 3221005 => 4, + 2121005 | 2221005 | 2321003 | 12111004 | GAVIOTA | 11001004 | 12001004 | 13001004 + | 14001005 | 15001004 => 3, + _ => 1, + }; + info.max(skill.effect.mob_count) +} + +/// Whether a summon's attacks are magic (the list at 0x7ad4e0). +fn magic(skill_id: i32) -> bool { + matches!( + skill_id, + 2121005 | 2221005 | 2321003 | 12111004 | 2311006 | 12001004 + ) +} + +/// The most one of a summon's hits can do, twice what the owner's client +/// can roll before its multipliers (the mob's element, criticals). A +/// physical hit is the owner's main stat times 2.5 plus the other +/// (FUN_0079216d), a magic one the v83 spell formula on their magic +/// capped at 1999 (FUN_00792595), each times the summon's `pad` or `mad` +/// percent. +pub fn max_damage(player: &Player, skill: &Learned) -> i32 { + let mut stat = [ + player.stats.str, + player.stats.dex, + player.stats.int, + player.stats.luk, + ] + .map(f64::from); + let mut matk = 0.0; + for (_, item) in player.inventory.items(InventoryType::Equipped) { + if let Some(equip) = &item.equip { + for (total, add) in stat + .iter_mut() + .zip([equip.str, equip.dex, equip.int, equip.luk]) + { + *total += f64::from(add); + } + matk += f64::from(equip.matk); + } + } + let [str, dex, int, luk] = stat; + let roll = if magic(skill.skill_id) { + let mad = player.buffs.value(BuffStat::Mad).map_or(0.0, f64::from); + let magic = (int + matk + mad).min(1999.0); + let squared = magic * 0.058; + (magic * 3.3 + int * 0.5 + squared * squared) * f64::from(skill.effect.magic) + } else { + let main = str.max(dex).max(luk); + (main * 2.5 + str + dex + luk - main) * f64::from(skill.effect.pad) + }; + ((roll / 100.0 * 2.0).ceil() as i32).max(10) +} + +/// How often a summon may attack (FUN_00765047). +pub fn attack_interval(skill_id: i32) -> std::time::Duration { + std::time::Duration::from_millis(match skill_id { + WRATH_OF_THE_OCTOPI | GAVIOTA => 1500, + _ => 3000, + }) +} + +/// What the owner's summons may do in one attack, for each summon skill +/// they have running: the field checks the attack against its summon's. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limit { + pub skill_id: i32, + pub mob_count: i32, + pub max_damage: i32, +} + +/// The limits for each summon the player has up. +pub fn limits(player: &Player) -> Vec { + player + .buffs + .active() + .iter() + .filter(|buff| kind(buff.skill_id).is_some()) + .filter_map(|buff| skills::learned(player, buff.skill_id)) + .map(|skill| Limit { + skill_id: skill.skill_id, + mob_count: mob_count(&skill), + max_damage: max_damage(player, &skill), + }) + .collect() +} + +/// One mob a summon hit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SummonHit { + pub object_id: i32, + /// How the mob reacts (its hit action). + pub action: u8, + pub damage: i32, +} + +/// SUMMON_ATTACK (recv 0xB0, built at 0x7a57d4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SummonAttack { + pub object_id: i32, + /// The animation, 4 for `attack1` or 5 for `attack2`, with 0x80 when + /// it faces left. + pub action: u8, + pub hits: Vec, +} + +pub fn parse_attack(packet: &Packet) -> Result { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let object_id = r.read_int()?; + r.read_int()?; // tick + let action = r.read_byte()?; + let count = r.read_byte()?; + r.read_bytes(8)?; // owner and summon positions + let mut hits = Vec::with_capacity(usize::from(count)); + for _ in 0..count { + let object_id = r.read_int()?; + r.read_int()?; // mob template + let action = r.read_byte()?; + // Facing and frame, the damage stat, positions, and delay. + r.read_bytes(1 + 1 + 1 + 8 + 2)?; + let damage = r.read_int()?; + hits.push(SummonHit { + object_id, + action, + damage, + }); + } + Ok(SummonAttack { + object_id, + action, + hits, + }) +} + +/// DAMAGE_SUMMON (recv 0xB1, 0x7a627a): a mob hurt a Puppet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SummonDamage { + pub object_id: i32, + /// The mob's attack: -1 for touching it, -2 for no mob. + pub attack: i8, + pub damage: i32, + /// The mob's template and whether it hit from the left. + pub mob: Option<(i32, bool)>, +} + +pub fn parse_damage(packet: &Packet) -> Result { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let object_id = r.read_int()?; + let attack = r.read_byte()? as i8; + let damage = r.read_int()?; + let mob = if attack > -2 { + Some((r.read_int()?, r.read_byte()? != 0)) + } else { + None + }; + Ok(SummonDamage { + object_id, + attack, + damage, + mob, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use game::SkillRecord; + use packet::io::write::PktWrite; + + fn knowing(skills: &[(i32, i32)]) -> Player { + let mut player = crate::codec::stats::sample_player(); + for &(skill_id, level) in skills { + player.skills.set(SkillRecord { + skill_id, + level, + master_level: 0, + expiration: None, + }); + } + player + } + + #[test] + fn summons_that_stay_put_show_the_puppet_icon() { + assert_eq!(kind(3111002), Some((Moves::Stationary, Assist::Nothing))); + assert_eq!(kind(OCTOPUS), Some((Moves::Stationary, Assist::Attack))); + assert_eq!(kind(3111005), Some((Moves::CircleFollow, Assist::Attack))); + assert_eq!(kind(BEHOLDER), Some((Moves::Follow, Assist::Support))); + assert_eq!(kind(3111003), None); + assert_eq!(stat(3211002), BuffStat::Puppet); + assert_eq!(stat(3211005), BuffStat::Combo); + } + + #[test] + fn a_summon_hits_by_its_pad_or_mad() { + // STR 35, DEX 4, LUK 4: (35 * 2.5 + 8) * 100% * 2. + let hawk = skills::at_level(3111005, 30).unwrap(); + let player = knowing(&[]); + assert_eq!(max_damage(&player, &hawk), 191); + let phoenix = skills::at_level(3121006, 30).unwrap(); + assert_eq!(mob_count(&phoenix), 4); + assert_eq!(max_damage(&player, &phoenix), 1051); + // Bahamut at 30 reaches 6, more than its attack's 3. + assert_eq!(mob_count(&skills::at_level(2321003, 30).unwrap()), 6); + let puppet = skills::at_level(3111002, 20).unwrap(); + assert_eq!(hp(&puppet), Some(6000)); + assert_eq!(hp(&hawk), None); + } + + #[test] + fn limits_cover_the_summons_up() { + let mut player = knowing(&[(3111005, 30), (3111002, 20)]); + let now = std::time::Instant::now(); + let lasts = std::time::Duration::from_secs(60); + player + .buffs + .give(game::Buff::new(3111005, BuffStat::Combo, 1, now, lasts)); + let limits = limits(&player); + assert_eq!(limits.len(), 1); + assert_eq!(limits[0].skill_id, 3111005); + assert_eq!(limits[0].mob_count, 1); + } + + #[test] + fn a_summon_attack_names_each_mob_and_its_damage() { + let mut p = Packet::new_empty(); + p.write_short(0xB0).unwrap(); + p.write_int(900).unwrap(); + p.write_int(1234).unwrap(); + p.write_byte(0x84).unwrap(); + p.write_byte(1).unwrap(); + p.write_bytes(&[0; 8]).unwrap(); + p.write_int(2_000_000_001).unwrap(); + p.write_int(100100).unwrap(); + p.write_byte(7).unwrap(); + p.write_bytes(&[0; 13]).unwrap(); + p.write_int(321).unwrap(); + p.write_int(0).unwrap(); + assert_eq!( + parse_attack(&p).unwrap(), + SummonAttack { + object_id: 900, + action: 0x84, + hits: vec![SummonHit { + object_id: 2_000_000_001, + action: 7, + damage: 321, + }], + } + ); + + let mut p = Packet::new_empty(); + p.write_short(0xB1).unwrap(); + p.write_int(900).unwrap(); + p.write_byte(0xFF).unwrap(); + p.write_int(55).unwrap(); + p.write_int(100100).unwrap(); + p.write_byte(1).unwrap(); + let damage = parse_damage(&p).unwrap(); + assert_eq!(damage.attack, -1); + assert_eq!(damage.damage, 55); + assert_eq!(damage.mob, Some((100100, true))); + } +} diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 4622ac875..db5ccfdd9 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -507,6 +507,7 @@ impl ClientActor { party, }) .await?; + self.bring_summons(spawn).await?; self.run_map_enter_script().await } @@ -944,6 +945,7 @@ impl ClientActor { up, } => self.take_fame(giver, giver_name, up).await?, ServerMessage::FameMissed { target } => self.fame_missed(target).await?, + ServerMessage::SummonBroken { skill_id } => self.summon_broken(skill_id).await?, ServerMessage::SendPacket(mut packet) => self.send(&mut packet).await?, } Ok(()) diff --git a/runtime/src/actor/client/buff.rs b/runtime/src/actor/client/buff.rs index 24b61a16f..05d39abb2 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -4,8 +4,9 @@ use crate::message::FieldMessage; use game::BuffStat; use net::packet::build::world::buff; use net::packet::build::world::stat::{self, StatUpdate}; +use net::packet::build::world::summon::Gone; use net::packet::handle::world::party_skill_reached; -use net::PartySkill; +use net::{summons, FieldRequest, PartySkill, SpawnPosition}; use packet::Packet; impl ClientActor { @@ -93,12 +94,82 @@ impl ClientActor { /// End every buff now, e.g. on death. Buffs don't follow the player to /// another channel either. pub(super) async fn cancel_buffs(&mut self) -> Result<(), RuntimeError> { - let ended = self.in_game_mut()?.player.buffs.clear(); + let buffs = &mut self.in_game_mut()?.player.buffs; + let summoned = buffs + .active() + .iter() + .any(|buff| summons::kind(buff.skill_id).is_some()); + let ended = buffs.clear(); if ended.is_empty() { return Ok(()); } + if summoned { + self.send_to_field(FieldMessage::FromClient { + from: self.client_id, + request: FieldRequest::Unsummon { + skill_id: None, + why: Gone::Dismissed, + }, + }) + .await?; + } let mut packet = buff::build_cancel_buff(&ended)?; self.send(&mut packet).await?; self.hide_buffs(&ended).await } + + /// End one buff, e.g. a Puppet's when a mob broke it. + async fn end_buff(&mut self, skill_id: i32) -> Result<(), RuntimeError> { + let ended = self.in_game_mut()?.player.buffs.cancel(skill_id); + if ended.is_empty() { + return Ok(()); + } + let mut packet = buff::build_cancel_buff(&ended)?; + self.send(&mut packet).await?; + self.hide_buffs(&ended).await + } + + pub(super) async fn summon_broken(&mut self, skill_id: i32) -> Result<(), RuntimeError> { + self.end_buff(skill_id).await + } + + /// On a new map the summons that follow the player come along, at + /// `spawn`; those that stay where they were put are gone. + pub(super) async fn bring_summons(&mut self, spawn: SpawnPosition) -> Result<(), RuntimeError> { + let player = &self.in_game()?.player; + let now = std::time::Instant::now(); + let summoned: Vec<_> = player + .buffs + .active() + .iter() + .filter(|buff| summons::kind(buff.skill_id).is_some()) + .map(|buff| { + let level = player.skills.level(buff.skill_id); + ( + buff.skill_id, + level, + buff.expires.saturating_duration_since(now), + ) + }) + .collect(); + for (skill_id, level, lasts) in summoned { + if summons::stationary(skill_id) { + self.end_buff(skill_id).await?; + continue; + } + self.send_to_field(FieldMessage::FromClient { + from: self.client_id, + request: FieldRequest::Summon { + skill_id, + level, + at: (spawn.x, spawn.y), + left: false, + lasts, + appear: false, + }, + }) + .await?; + } + Ok(()) + } } diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index f574a57d9..d00646308 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -2,6 +2,7 @@ use crate::actor::field_drops::{Drop, DropSet}; use crate::actor::field_mists::MistSet; use crate::actor::field_mobs::{split_exp, MobSet, Sharer}; use crate::actor::field_reactors::{Hit, ReactorSet}; +use crate::actor::field_summons::SummonSet; use crate::message::{ClientHandle, FieldKey, FieldMessage, ServerMessage}; use game::FieldAvatar; use net::packet::build::world::drop::{build_drop, build_remove_drop, DropAppearance}; @@ -20,7 +21,12 @@ use net::packet::build::world::npc::{ use net::packet::build::world::reactor::{ build_hit_reactor, build_remove_reactor, build_spawn_reactor, }; +use net::packet::build::world::summon::{ + build_damage_summon, build_move_summon, build_remove_summon, build_spawn_summon, + build_summon_attack, Gone, SummonView, +}; use net::skills::{self, Inflict}; +use net::summons::{self, Limit, SummonAttack, SummonDamage}; use net::{ClientId, FieldRequest}; use packet::Packet; use std::collections::HashMap; @@ -56,9 +62,14 @@ const QUEST_NPC_REACH_Y: i32 = 800; /// servers; v83's client starts the mist's time then). const MIST_DELAY: i16 = 8; +/// How far from its owner a client may put a summon: a Puppet goes 200 px +/// ahead, onto a foothold within 80 px up or down (FUN_0096b140). +const SUMMON_REACH_X: i32 = 250; +const SUMMON_REACH_Y: i32 = 150; + /// One live copy of a map. It owns everything local to that map: who is on /// it and where, its NPCs, mobs, drops, and reactors, which advance on -/// `tick`. +/// `tick`, and its players' mists and summons. pub struct FieldActor { key: FieldKey, rx: mpsc::Receiver, @@ -70,6 +81,7 @@ pub struct FieldActor { drops: DropSet, reactors: ReactorSet, mists: MistSet, + summons: SummonSet, tick: Duration, } @@ -95,6 +107,7 @@ impl FieldActor { drops, reactors, mists: MistSet::default(), + summons: SummonSet::default(), tick, } } @@ -136,6 +149,12 @@ impl FieldActor { } } self.reach_from_mists(now); + for summon in self.summons.expire(now) { + let view = summon.view; + if let Ok(packet) = build_remove_summon(view.owner_id, view.object_id, Gone::Expired) { + self.broadcast(None, packet); + } + } for hurt in self.mobs.hurt_by_statuses(now) { if let (Some(occupant), Ok(packet)) = ( self.occupants.get(&hurt.from), @@ -335,6 +354,26 @@ impl FieldActor { giver_name, up, } => self.handle_fame(from, target, giver_name, up), + FieldRequest::Summon { + skill_id, + level, + at, + left, + lasts, + appear, + } => self.handle_summon(from, skill_id, level, at, left, lasts, appear), + FieldRequest::Unsummon { skill_id, why } => { + self.handle_unsummon(from, skill_id, why) + } + FieldRequest::SummonMove { + object_id, + start, + movement, + } => self.handle_summon_move(from, object_id, start, movement), + FieldRequest::SummonAttack { attack, limits } => { + self.handle_summon_attack(from, attack, &limits) + } + FieldRequest::SummonDamage(damage) => self.handle_summon_damage(from, damage), }, FieldMessage::SpawnLoot { owner, @@ -659,6 +698,152 @@ impl FieldActor { } } + /// `from` called up a summon. It replaces their summon of that kind, + /// which the others see dismissed. + #[allow(clippy::too_many_arguments)] + fn handle_summon( + &mut self, + from: ClientId, + skill_id: i32, + level: i32, + at: (i16, i16), + left: bool, + lasts: Duration, + appear: bool, + ) { + let Some(owner) = self.occupants.get(&from) else { + return; + }; + let (Some((moves, assist)), Some(skill)) = + (summons::kind(skill_id), skills::at_level(skill_id, level)) + else { + return; + }; + let avatar = &owner.avatar; + let near = (i32::from(at.0) - i32::from(avatar.x)).abs() <= SUMMON_REACH_X + && (i32::from(at.1) - i32::from(avatar.y)).abs() <= SUMMON_REACH_Y; + let (x, y) = if near { at } else { (avatar.x, avatar.y) }; + let view = SummonView { + owner_id: avatar.id, + object_id: 0, + skill_id, + owner_level: avatar.level, + level: level as u8, + x, + y, + stance: u8::from(left), + moves, + assist, + }; + let hp = summons::hp(&skill); + let (view, replaced) = self.summons.add(view, from, hp, lasts, Instant::now()); + if let Some(old) = replaced { + self.remove_summon(&old.view, Gone::Dismissed); + } + if let Ok(packet) = build_spawn_summon(&view, appear) { + self.broadcast(None, packet); + } + } + + fn handle_unsummon(&mut self, from: ClientId, skill_id: Option, why: Gone) { + for summon in self.summons.remove_owned(from, skill_id) { + self.remove_summon(&summon.view, why); + } + } + + fn remove_summon(&self, view: &SummonView, why: Gone) { + if let Ok(packet) = build_remove_summon(view.owner_id, view.object_id, why) { + self.broadcast(None, packet); + } + } + + fn handle_summon_move( + &mut self, + from: ClientId, + object_id: i32, + start: (i16, i16), + movement: Vec, + ) { + let Some(summon) = self.summons.owned(from, object_id) else { + return; + }; + let view = &mut summon.view; + if let Some((x, y, stance)) = + parse_movement_state(&movement, (start.0, start.1, view.stance)) + { + (view.x, view.y, view.stance) = (x, y, stance); + } + let owner_id = view.owner_id; + if let Ok(packet) = build_move_summon(owner_id, object_id, start, &movement) { + self.broadcast(Some(from), packet); + } + } + + /// `from`'s summon attacked. An attack beyond its skill's reach or + /// damage cap, or one that hits a mob twice, counts for nothing. + fn handle_summon_attack(&mut self, from: ClientId, attack: SummonAttack, limits: &[Limit]) { + let Some(summon) = self.summons.owned(from, attack.object_id) else { + return; + }; + let view = summon.view; + let Some(limit) = limits.iter().find(|limit| limit.skill_id == view.skill_id) else { + return; + }; + let hits = &attack.hits; + let mut mobs: Vec = hits.iter().map(|hit| hit.object_id).collect(); + mobs.sort_unstable(); + mobs.dedup(); + if hits.len() > limit.mob_count as usize + || mobs.len() != hits.len() + || hits + .iter() + .any(|hit| !(0..=limit.max_damage).contains(&hit.damage)) + { + warn!( + from, + skill_id = view.skill_id, + ?attack, + ?limit, + "Summon attack refused" + ); + return; + } + let Ok(broadcast) = build_summon_attack(&view, attack.action, hits) else { + return; + }; + let hits = hits + .iter() + .map(|hit| (hit.object_id, i64::from(hit.damage))) + .collect(); + self.handle_attack(from, broadcast, hits, &[]); + } + + /// A mob hurt `from`'s Puppet: the others see it, and it breaks at + /// 0 HP, ending its owner's buff. + fn handle_summon_damage(&mut self, from: ClientId, damage: SummonDamage) { + let Some(summon) = self.summons.owned(from, damage.object_id) else { + return; + }; + let Some(hp) = summon.hp.as_mut() else { + return; + }; + *hp -= damage.damage; + let (broken, view) = (*hp <= 0, summon.view); + if let Ok(packet) = build_damage_summon(view.owner_id, &damage) { + self.broadcast(Some(from), packet); + } + if !broken { + return; + } + self.summons.remove(view.object_id); + self.remove_summon(&view, Gone::Broken); + if let Some(owner) = self.occupants.get(&from) { + owner.handle.send(ServerMessage::SummonBroken { + skill_id: view.skill_id, + }); + } + } + /// Each mist due puts its statuses, with its chance, on the live mobs /// in it that lack them (OdinMS-family servers). fn reach_from_mists(&mut self, now: Instant) { @@ -803,6 +988,10 @@ impl FieldActor { let client_id = handle.client_id; // A rejoin replaces the previous entry without announcing a leave. self.occupants.remove(&client_id); + // Its summons come back with it. + for summon in self.summons.remove_owned(client_id, None) { + self.remove_summon(&summon.view, Gone::Silently); + } for map_npc in &self.map_npcs { match build_spawn_npc(&to_foreign_npc(map_npc)) { @@ -847,6 +1036,13 @@ impl FieldActor { } } + // After their owners, whose summons they are. + for summon in self.summons.views() { + if let Ok(packet) = build_spawn_summon(summon, false) { + handle.send_packet(packet); + } + } + let now = Instant::now(); for drop in self.drops.all().filter(|drop| drop.visible_to(client_id)) { if let Ok(packet) = build_drop(&drop.view(now), DropAppearance::Lying) { @@ -874,6 +1070,8 @@ impl FieldActor { if self.occupants.remove(&client_id).is_none() { return; } + // The others drop a player's summons with the player. + self.summons.remove_owned(client_id, None); // Everyone sees the player go before control passes on. match build_player_leave_field(client_id) { Ok(packet) => self.broadcast(None, packet), @@ -1472,4 +1670,171 @@ mod tests { } assert!(first_rx.try_recv().is_err()); } + + /// Two players on a field with a long tick, everything they got so far + /// read. + async fn two_players() -> ( + mpsc::Sender, + mpsc::Receiver, + mpsc::Receiver, + ) { + let field = spawn_field(); + let (first, mut first_rx) = client(1); + let (second, mut second_rx) = client(2); + for (handle, id) in [(first, 1), (second, 2)] { + field + .send(FieldMessage::Join { + handle, + avatar: avatar(id, 0, 0), + party: None, + }) + .await + .unwrap(); + } + opcodes(&mut first_rx).await; + opcodes(&mut second_rx).await; + (field, first_rx, second_rx) + } + + fn summon(skill_id: i32, at: (i16, i16), lasts: Duration) -> FieldMessage { + FieldMessage::FromClient { + from: 1, + request: FieldRequest::Summon { + skill_id, + level: 20, + at, + left: true, + lasts, + appear: true, + }, + } + } + + #[tokio::test] + async fn a_puppet_shows_to_all_takes_its_owners_hits_and_breaks() { + if skills::data().is_err() { + return; + } + let (field, mut first_rx, mut second_rx) = two_players().await; + let puppet = summons::PUPPETS[0]; + // Too far from its owner: it goes where they stand. + field + .send(summon(puppet, (900, 0), Duration::from_secs(60))) + .await + .unwrap(); + let spawned = next_packet(&mut second_rx).await; + assert_eq!(spawned.opcode(), SendOpcode::SpawnSummon as i16); + let object_id = i32::from_le_bytes(spawned.bytes[6..10].try_into().unwrap()); + assert_eq!(&spawned.bytes[16..20], [0, 0, 0, 0]); + assert_eq!( + next_opcode(&mut first_rx).await, + SendOpcode::SpawnSummon as i16 + ); + + // Someone arriving sees it after its owner. + let (third, mut third_rx) = client(3); + field + .send(FieldMessage::Join { + handle: third, + avatar: avatar(3, 0, 0), + party: None, + }) + .await + .unwrap(); + let seen = opcodes(&mut third_rx).await; + let at = |op: SendOpcode| { + let op = op as i16; + seen.iter().position(|&seen| seen == op).unwrap() + }; + assert!(at(SendOpcode::SpawnPlayer) < at(SendOpcode::SpawnSummon)); + opcodes(&mut first_rx).await; + opcodes(&mut second_rx).await; + + let hit = |from, damage| FieldMessage::FromClient { + from, + request: FieldRequest::SummonDamage(SummonDamage { + object_id, + attack: -1, + damage, + mob: Some((100100, false)), + }), + }; + // Only its owner's client says what hurt it. + field.send(hit(2, 1_000_000)).await.unwrap(); + field.send(hit(1, 10)).await.unwrap(); + assert_eq!( + next_opcode(&mut second_rx).await, + SendOpcode::DamageSummon as i16 + ); + let hp = summons::hp(&skills::at_level(puppet, 20).unwrap()).unwrap(); + field.send(hit(1, hp)).await.unwrap(); + assert_eq!( + next_opcode(&mut second_rx).await, + SendOpcode::DamageSummon as i16 + ); + let removed = next_packet(&mut second_rx).await; + assert_eq!(removed.opcode(), SendOpcode::RemoveSummon as i16); + assert_eq!(removed.bytes[10], Gone::Broken as u8); + assert_eq!( + next_opcode(&mut first_rx).await, + SendOpcode::RemoveSummon as i16 + ); + match timeout(Duration::from_secs(1), first_rx.recv()).await { + Ok(Some(ServerMessage::SummonBroken { skill_id })) => assert_eq!(skill_id, puppet), + other => panic!("expected the puppet broken, got {other:?}"), + } + } + + #[tokio::test] + async fn a_summon_attacks_within_its_limits_and_goes_when_its_time_is_up() { + if skills::data().is_err() { + return; + } + let (field, mut first_rx, mut second_rx) = two_players().await; + let hawk = 3111005; + field + .send(summon(hawk, (0, 0), Duration::from_millis(300))) + .await + .unwrap(); + let spawned = next_packet(&mut second_rx).await; + let object_id = i32::from_le_bytes(spawned.bytes[6..10].try_into().unwrap()); + opcodes(&mut first_rx).await; + + let attack = |damage: &[i32]| FieldMessage::FromClient { + from: 1, + request: FieldRequest::SummonAttack { + attack: SummonAttack { + object_id, + action: 0x84, + hits: damage + .iter() + .enumerate() + .map(|(i, &damage)| summons::SummonHit { + object_id: 2_000_000_000 + i as i32, + action: 6, + damage, + }) + .collect(), + }, + limits: vec![Limit { + skill_id: hawk, + mob_count: 1, + max_damage: 100, + }], + }, + }; + field.send(attack(&[101])).await.unwrap(); + field.send(attack(&[50, 50])).await.unwrap(); + field.send(attack(&[100])).await.unwrap(); + // Only the last keeps to the limits; its owner already saw it. + assert_eq!( + opcodes(&mut second_rx).await, + [SendOpcode::SummonAttack as i16] + ); + assert!(first_rx.try_recv().is_err()); + + let removed = next_packet(&mut second_rx).await; + assert_eq!(removed.opcode(), SendOpcode::RemoveSummon as i16); + assert_eq!(removed.bytes[10], Gone::Expired as u8); + } } diff --git a/runtime/src/actor/field_summons.rs b/runtime/src/actor/field_summons.rs new file mode 100644 index 000000000..632258387 --- /dev/null +++ b/runtime/src/actor/field_summons.rs @@ -0,0 +1,177 @@ +//! The summons on one field: each player's birds, puppets, and octopi. +//! Pure state; `FieldActor` sends the packets. + +use crate::actor::object_ids::{self, ObjectIds}; +use net::packet::build::world::summon::SummonView; +use net::summons; +use net::ClientId; +use std::collections::BTreeMap; +use tokio::time::{Duration, Instant}; + +pub struct Summon { + pub view: SummonView, + pub owner: ClientId, + /// A Puppet's HP; the others can't be hurt. + pub hp: Option, + until: Instant, +} + +pub struct SummonSet { + summons: BTreeMap, + object_ids: ObjectIds, +} + +impl Default for SummonSet { + fn default() -> Self { + Self { + summons: BTreeMap::new(), + object_ids: ObjectIds::new(object_ids::SUMMONS), + } + } +} + +impl SummonSet { + /// Summon `view` for `lasts`, filling in its object id. A player has + /// one summon of each kind (`summons::stat`): the one it replaces is + /// returned. + pub fn add( + &mut self, + mut view: SummonView, + owner: ClientId, + hp: Option, + lasts: Duration, + now: Instant, + ) -> (SummonView, Option) { + let stat = summons::stat(view.skill_id); + let replaced = self + .summons + .values() + .find(|summon| summon.owner == owner && summons::stat(summon.view.skill_id) == stat) + .map(|summon| summon.view.object_id) + .and_then(|object_id| self.summons.remove(&object_id)); + view.object_id = self.object_ids.next(); + self.summons.insert( + view.object_id, + Summon { + view, + owner, + hp, + until: now + lasts, + }, + ); + (view, replaced) + } + + /// `owner`'s summon `object_id`. + pub fn owned(&mut self, owner: ClientId, object_id: i32) -> Option<&mut Summon> { + self.summons + .get_mut(&object_id) + .filter(|summon| summon.owner == owner) + } + + pub fn remove(&mut self, object_id: i32) -> Option { + self.summons.remove(&object_id) + } + + /// Remove `owner`'s summons of `skill_id`, or all of them. + pub fn remove_owned(&mut self, owner: ClientId, skill_id: Option) -> Vec { + let gone: Vec = self + .summons + .values() + .filter(|summon| { + summon.owner == owner && skill_id.is_none_or(|id| id == summon.view.skill_id) + }) + .map(|summon| summon.view.object_id) + .collect(); + gone.iter() + .filter_map(|object_id| self.summons.remove(object_id)) + .collect() + } + + /// Remove the summons whose time is up. + pub fn expire(&mut self, now: Instant) -> Vec { + let gone: Vec = self + .summons + .values() + .filter(|summon| summon.until <= now) + .map(|summon| summon.view.object_id) + .collect(); + gone.iter() + .filter_map(|object_id| self.summons.remove(object_id)) + .collect() + } + + pub fn views(&self) -> impl Iterator { + self.summons.values().map(|summon| &summon.view) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use net::summons::{Assist, Moves}; + + fn view(owner_id: i32, skill_id: i32) -> SummonView { + let (moves, assist) = summons::kind(skill_id).unwrap(); + SummonView { + owner_id, + object_id: 0, + skill_id, + owner_level: 70, + level: 20, + x: 0, + y: 0, + stance: 0, + moves, + assist, + } + } + + const HAWK: i32 = 3111005; + const PUPPET: i32 = 3111002; + + #[test] + fn a_player_has_one_bird_and_one_puppet_until_their_time_is_up() { + let mut set = SummonSet::default(); + let now = Instant::now(); + let lasts = Duration::from_secs(60); + let (hawk, replaced) = set.add(view(7, HAWK), 1, None, lasts, now); + assert_eq!(hawk.object_id, object_ids::SUMMONS.start); + assert!(replaced.is_none()); + assert_eq!( + (hawk.moves, hawk.assist), + (Moves::CircleFollow, Assist::Attack) + ); + let (puppet, replaced) = set.add(view(7, PUPPET), 1, Some(4000), lasts, now); + assert!(replaced.is_none()); + set.add(view(8, HAWK), 2, None, lasts, now); + + let later = now + Duration::from_secs(10); + let (again, replaced) = set.add(view(7, HAWK), 1, None, lasts, later); + assert_eq!(replaced.unwrap().view.object_id, hawk.object_id); + assert_eq!(set.views().count(), 3); + + assert!(set.owned(2, puppet.object_id).is_none()); + assert_eq!(set.owned(1, puppet.object_id).unwrap().hp, Some(4000)); + + let gone = set.expire(now + lasts); + let gone: Vec = gone.iter().map(|summon| summon.view.object_id).collect(); + assert_eq!(gone.len(), 2); + assert!(!gone.contains(&again.object_id)); + } + + #[test] + fn an_owner_dismisses_one_kind_or_all_of_theirs() { + let mut set = SummonSet::default(); + let now = Instant::now(); + let lasts = Duration::from_secs(60); + set.add(view(7, HAWK), 1, None, lasts, now); + set.add(view(7, PUPPET), 1, Some(4000), lasts, now); + set.add(view(8, HAWK), 2, None, lasts, now); + let gone = set.remove_owned(1, Some(PUPPET)); + assert_eq!(gone.len(), 1); + assert_eq!(gone[0].view.skill_id, PUPPET); + assert_eq!(set.remove_owned(1, None).len(), 1); + assert_eq!(set.views().count(), 1); + } +} diff --git a/runtime/src/actor/mod.rs b/runtime/src/actor/mod.rs index 7bae8a57b..9a776e21c 100644 --- a/runtime/src/actor/mod.rs +++ b/runtime/src/actor/mod.rs @@ -5,6 +5,7 @@ pub mod field_drops; pub mod field_mists; pub mod field_mobs; pub mod field_reactors; +pub mod field_summons; pub mod login; pub mod messenger; pub mod object_ids; diff --git a/runtime/src/actor/object_ids.rs b/runtime/src/actor/object_ids.rs index bca470dbe..112fd7e67 100644 --- a/runtime/src/actor/object_ids.rs +++ b/runtime/src/actor/object_ids.rs @@ -3,6 +3,7 @@ use std::ops::Range; +pub const SUMMONS: Range = 900_000_000..1_000_000_000; pub const MISTS: Range = 1_100_000_000..1_200_000_000; pub const REACTORS: Range = 1_200_000_000..1_500_000_000; pub const DROPS: Range = 1_500_000_000..2_000_000_000; diff --git a/runtime/src/message.rs b/runtime/src/message.rs index d147e609b..203354839 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -112,6 +112,10 @@ pub enum ServerMessage { FameMissed { target: i32, }, + /// A mob broke this player's Puppet: its buff ends. + SummonBroken { + skill_id: i32, + }, SendPacket(Packet), } From aad39282b89c9efd03733120ffc696b410e2c5c9 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 10:26:23 -0400 Subject: [PATCH 2/4] L158 B: mobs turn on the player's Puppet, and their touch hurts it A local Puppet's spawn makes each mob this client moves that is chasing someone chase the Puppet for 3-4 minutes; its removal lets them go (FUN_00679847). A mob touching an unblinking Puppet rolls 40/(lv+70) times its attack times 10-85% of it, over 100 (FUN_00793687), shows it, and sends DAMAGE_SUMMON (0x7a627a). --- HeavenClient/Gameplay/Combat/Combat.cpp | 18 +++++++++ HeavenClient/Gameplay/MapleMap/MapMobs.cpp | 32 +++++++++++++-- HeavenClient/Gameplay/MapleMap/MapMobs.h | 11 ++++++ HeavenClient/Gameplay/MapleMap/MapSummons.cpp | 24 ++++++++++++ HeavenClient/Gameplay/MapleMap/MapSummons.h | 5 +++ HeavenClient/Gameplay/MapleMap/Mob.cpp | 39 ++++++++++++++++++- HeavenClient/Gameplay/MapleMap/Mob.h | 14 +++++++ HeavenClient/Gameplay/MapleMap/SummonRules.h | 17 ++++++++ .../Net/Handlers/MapObjectHandlers.cpp | 12 +++++- .../Net/Packets/AttackAndSkillPackets.h | 16 ++++++++ HeavenClient/tests/summon_rules.cpp | 2 + 11 files changed, 184 insertions(+), 6 deletions(-) diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 9b3152538..ef5dacabc 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -396,6 +396,24 @@ namespace ms void Combat::update_summons() { + // A mob that touches the player's Puppet hurts it; the server breaks + // it at 0 HP (FUN_007a3f4d, FUN_007a6040). + for (auto &[oid, summon] : summons.get_local()) + { + if (!summon->can_be_hit()) + continue; + + int32_t mob = mobs.find_touching(summon->get_body()); + + if (!mob) + continue; + + int32_t damage = mobs.roll_puppet_damage(mob, summon->get_level()); + bool left = mobs.get_mob_position(mob).x() < summon->get_position().x(); + summons.hurt(oid, damage); + DamageSummonPacket(oid, damage, mobs.get_mob_id(mob), left).dispatch(); + } + if (player.is_dead()) return; diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp index de47397dd..7cad901c9 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp @@ -183,14 +183,18 @@ namespace ms vertical.greater() }; + return find_touching(player_rect); + } + + int32_t MapMobs::find_touching(const Rectangle &area) const + { auto iter = std::find_if( mobs.begin(), mobs.end(), - [&player_rect](auto &mmo) + [&area](auto &mmo) { Optional mob = mmo.second.get(); - return mob && mob->is_alive() && mob->deals_touch_damage() && - mob->is_in_range(player_rect); + return mob && mob->is_alive() && mob->deals_touch_damage() && mob->is_in_range(area); } ); @@ -200,6 +204,28 @@ namespace ms return iter->second->get_oid(); } + void MapMobs::aggro_puppet(int32_t oid, Point position) + { + for (auto &mmo : mobs) + if (Optional mob = mmo.second.get()) + mob->aggro_puppet(oid, position); + } + + void MapMobs::release_puppet(int32_t oid) + { + for (auto &mmo : mobs) + if (Optional mob = mmo.second.get()) + mob->release_puppet(oid); + } + + int32_t MapMobs::roll_puppet_damage(int32_t oid, int32_t level) const + { + if (Optional mob = mobs.get(oid)) + return mob->roll_puppet_damage(level); + + return 0; + } + MobAttack MapMobs::create_attack(int32_t oid) const { if (Optional mob = mobs.get(oid)) diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.h b/HeavenClient/Gameplay/MapleMap/MapMobs.h index 4a0bb795b..13ed46cd4 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.h +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.h @@ -90,6 +90,17 @@ namespace ms // Return the id of the first mob who collides with the object. int32_t find_colliding(const MovingObject &moveobj) const; + // The first mob whose touch hurts that overlaps `area`, or 0. + int32_t find_touching(const Rectangle &area) const; + + // The player's Puppet came, or went: the mobs this client moves turn + // on it, or give it up. + void aggro_puppet(int32_t oid, Point position); + void release_puppet(int32_t oid); + + // Roll what mob `oid`'s touch does to a Puppet of skill level `level`. + int32_t roll_puppet_damage(int32_t oid, int32_t level) const; + // Create an attack by the specified mob. MobAttack create_attack(int32_t oid) const; diff --git a/HeavenClient/Gameplay/MapleMap/MapSummons.cpp b/HeavenClient/Gameplay/MapleMap/MapSummons.cpp index 474928a39..0d6c68c89 100644 --- a/HeavenClient/Gameplay/MapleMap/MapSummons.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapSummons.cpp @@ -313,6 +313,30 @@ namespace ms return !dying && oneshot == NONE && can_act(moves, skill); } + bool Summon::can_be_hit() const + { + return !dying && !removed && !can_act(moves, skill) && hitcountdown == 0; + } + + Rectangle Summon::get_body() const + { + auto iter = animations.find(showing); + Point position = get_position(); + + if (iter == animations.end()) + return Rectangle(position, position); + + Rectangle bounds = iter->second.get_bounds(); + + // The art faces left; facing right mirrors it. + if (left) + return Rectangle(position.x() + bounds.left(), position.x() + bounds.right(), + position.y() + bounds.top(), position.y() + bounds.bottom()); + + return Rectangle(position.x() - bounds.right(), position.x() - bounds.left(), + position.y() + bounds.top(), position.y() + bounds.bottom()); + } + void MapSummons::spawn(int32_t owner, int32_t oid, int32_t skill, uint8_t level, Point position, uint8_t action, uint8_t moves, uint8_t assist, bool appear, bool local) { diff --git a/HeavenClient/Gameplay/MapleMap/MapSummons.h b/HeavenClient/Gameplay/MapleMap/MapSummons.h index f79fec5af..7b7969c0c 100644 --- a/HeavenClient/Gameplay/MapleMap/MapSummons.h +++ b/HeavenClient/Gameplay/MapleMap/MapSummons.h @@ -75,6 +75,11 @@ namespace ms int8_t get_layer() const; // Whether it may attack: it can act, and plays nothing. bool is_idle() const; + // Whether a mob's touch hurts it: a Puppet, not blinking from the + // last hit (FUN_007a6040). + bool can_be_hit() const; + // What it covers on the map, for touches (FUN_007a38d6). + Rectangle get_body() const; // When it last attacked, in the combat's ms. int64_t last_attack = 0; diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index 494bc8864..8f6c4ff45 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #include "Mob.h" #include "Layer.h" +#include "SummonRules.h" #include "../Combat/Heal.h" #include "../Combat/HitPoint.h" @@ -588,6 +589,9 @@ namespace ms hastarget = false; } + if (!hastarget) + puppet = 0; + // v83 (FUN_005cc410) picks a walking mob's action as it goes: // its chase animation for as long as it has a target. if (!canfly && (bodystance == Stance::MOVE || bodystance == Stance::CHASE)) @@ -1089,8 +1093,41 @@ namespace ms void Mob::set_target(Point position, bool dead) { + targetpos = puppet ? puppetpos : position; + targetdead = puppet ? false : dead; + } + + void Mob::aggro_puppet(int32_t oid, Point position) + { + if (!control || !hastarget) + return; + + targettime = 180000 + randomizer.next_int(60000); + + if (puppet) + return; + + puppet = oid; + puppetpos = position; targetpos = position; - targetdead = dead; + targetdead = false; + } + + void Mob::release_puppet(int32_t oid) + { + if (puppet != oid) + return; + + puppet = 0; + hastarget = false; + } + + int32_t Mob::roll_puppet_damage(int32_t level) const + { + auto found = statuses.find(MobStatParser::PAD); + int32_t attack = watk + (found != statuses.end() ? static_cast(found->second.value) : 0); + + return summon_rules::touch_damage(attack, level, randomizer.next_real(1.0)); } void Mob::send_movement(Point, std::vector &&in_movements) diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 713f25a83..6911d76f6 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -98,6 +98,17 @@ namespace ms // Tell the mob where the local player is, whom it chases with aggro. void set_target(Point position, bool dead); + // The player's Puppet `oid` came at `position`: a mob this client + // moves that chases someone turns on it, and keeps to it until it + // goes (FUN_00679847, 0x9bbd88). + void aggro_puppet(int32_t oid, Point position); + + // Puppet `oid` went: a mob that chased it chases no one. + void release_puppet(int32_t oid); + + // Roll what its touch does to a Puppet of skill level `level`. + int32_t roll_puppet_damage(int32_t level) const; + // 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); @@ -397,6 +408,9 @@ namespace ms int32_t targettime; Point targetpos; bool targetdead; + // The Puppet it chases instead, if any, and where it stands. + int32_t puppet = 0; + Point puppetpos; // Ms until a firstAttack mob next reports the local player's distance. int16_t reporttime; // Ms towards the next AI step, and AI steps until the next decision. diff --git a/HeavenClient/Gameplay/MapleMap/SummonRules.h b/HeavenClient/Gameplay/MapleMap/SummonRules.h index a5bad0c3b..28488fe85 100644 --- a/HeavenClient/Gameplay/MapleMap/SummonRules.h +++ b/HeavenClient/Gameplay/MapleMap/SummonRules.h @@ -332,6 +332,23 @@ namespace ms::summon_rules return std::max(1, static_cast(damage)); } + // What a mob's touch does to a Puppet of skill level `level` + // (FUN_00793687): its attack `attack` (PADamage and any PAD up) times a + // roll of 10-85% of itself, times 40 / (level + 70), over 100. `roll` is + // in [0, 1). + inline int32_t touch_damage(int32_t attack, int32_t level, double roll) + { + double low = attack * 0.1; + double high = attack * 0.85; + + if (low > high) + std::swap(low, high); + + double rolled = low + (high - low) * roll; + + return static_cast(40.0 / (level + 70) * attack * rolled * 0.01); + } + // The main stat of a summoner's job: STR for warriors, LUK for // thieves, DEX for bowmen and pirates, INT for magicians. enum MainStat diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 9966b82ce..183e73d7c 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -613,8 +613,15 @@ namespace ms uint8_t assist = recv.read_byte(); bool appear = recv.length() > 0 ? recv.read_byte() == 0 : true; - Stage::get().get_summons().spawn(owner, oid, skillid, level, position, action, moves, assist, appear, - Stage::get().is_player(owner)); + bool local = Stage::get().is_player(owner); + Stage::get().get_summons().spawn(owner, oid, skillid, level, position, action, moves, assist, appear, local); + + // The mobs this client moves that chase someone turn on the player's + // Puppet (FUN_00679847). + Summon *summon = Stage::get().get_summons().get(oid); + + if (local && summon && summon_rules::is_puppet(skillid)) + Stage::get().get_mobs().aggro_puppet(oid, summon->get_position()); } void RemoveSummonHandler::handle(InPacket& recv) const @@ -625,6 +632,7 @@ namespace ms uint8_t reason = recv.read_byte(); int32_t skillid = Stage::get().get_summons().remove(oid, reason); + Stage::get().get_mobs().release_puppet(oid); if (skillid == 0 || !Stage::get().is_player(owner) || (reason != 0 && reason != 3)) return; diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 07d4ce149..6a3b98625 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -259,6 +259,22 @@ namespace ms } }; + // A mob touched the player's Puppet for `damage` (0x7a627a): attack -1 + // is its body, and `left` whether it came from the left. + // Opcode: DAMAGE_SUMMON(177) + class DamageSummonPacket : public OutPacket + { + public: + DamageSummonPacket(int32_t oid, int32_t damage, int32_t mobid, bool left) : OutPacket(OutPacket::Opcode::DAMAGE_SUMMON) + { + write_int(oid); + write_byte(-1); + write_int(damage); + write_int(mobid); + write_byte(left); + } + }; + // Tells the server which mobs Monster Magnet reached: each one's oid, 0 // for one out of sight, and whether it was caught (0x96c62a). It may // reach none. diff --git a/HeavenClient/tests/summon_rules.cpp b/HeavenClient/tests/summon_rules.cpp index a72e279ce..5073c3bbe 100644 --- a/HeavenClient/tests/summon_rules.cpp +++ b/HeavenClient/tests/summon_rules.cpp @@ -91,6 +91,8 @@ int main() require(physical_damage(100, 40, 100, 0.0) == 215 && physical_damage(100, 40, 100, 0.999999) == 289, "a hit is 70-100% of the main stat times 2.5, plus the others"); require(physical_damage(100, 40, 50, 0.0) == 107, "times the summon's pad percent"); + require(touch_damage(300, 20, 0.5) == 189 && touch_damage(300, 20, 0.999999) == 339 && + touch_damage(12, 20, 0.5) == 0, "a Puppet takes the square of the mob's attack, scaled down"); require(main_stat(311) == DEX && main_stat(412) == LUK && main_stat(212) == INT && main_stat(1100) == STR && main_stat(520) == DEX, "each job has its main stat"); From fb6db67d007227c08cee1c432775ed1aa4d9447c Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 10:47:31 -0400 Subject: [PATCH 3/4] L158 B: a Dark Knight's Beholder heals and hexes them The client heals its owner with Aura of the Beholder, else buffs one stat with Hex, each in its skill's time (FUN_007a4080, FUN_007a5ab4, FUN_007a5c99), and sends BEHOLDER (0xB2). The server checks the skills, holds each to its pace in the field, heals or gives the Hex buff, and shows the others SUMMON_SKILL (0xB3). --- HeavenClient/Character/Player.cpp | 5 + HeavenClient/Character/Player.h | 2 + HeavenClient/Gameplay/Combat/Combat.cpp | 87 ++++++++ HeavenClient/Gameplay/Combat/Combat.h | 5 + HeavenClient/Gameplay/MapleMap/MapSummons.h | 4 + HeavenClient/Gameplay/MapleMap/SummonRules.h | 74 +++++++ HeavenClient/Net/OutPacket.h | 1 + .../Net/Packets/AttackAndSkillPackets.h | 18 ++ HeavenClient/tests/summon_rules.cpp | 18 ++ agent_docs/combat.md | 34 +++- integration-harness/src/hunting.rs | 21 ++ integration-harness/tests/summons.rs | 101 ++++++++- net/src/handler.rs | 10 +- net/src/packet/build/world/summon.rs | 24 +++ net/src/packet/handle/world/mod.rs | 4 +- net/src/packet/handle/world/summon.rs | 21 ++ net/src/packet/op/recv.rs | 1 + net/src/packet/op/send.rs | 1 + net/src/summons.rs | 191 ++++++++++++++++++ runtime/src/actor/client.rs | 1 + runtime/src/actor/client/buff.rs | 28 +++ runtime/src/actor/field.rs | 30 ++- runtime/src/actor/field_summons.rs | 66 +++++- runtime/src/message.rs | 2 + 24 files changed, 736 insertions(+), 13 deletions(-) diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 2399584ff..39aafc77c 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -744,6 +744,11 @@ namespace ms return buffs[stat].value > 0; } + int16_t Player::get_buff_value(Buffstat::Id stat) const + { + return buffs[stat].value; + } + void Player::change_skill(int32_t skill_id, int32_t skill_level, int32_t masterlevel, int64_t expiration) { int32_t old_level = skillbook.get_level(skill_id); diff --git a/HeavenClient/Character/Player.h b/HeavenClient/Character/Player.h index 1c0039377..85514fd80 100644 --- a/HeavenClient/Character/Player.h +++ b/HeavenClient/Character/Player.h @@ -135,6 +135,8 @@ namespace ms // Return whether the buff is active. bool has_buff(Buffstat::Id stat) const; + // What a buff adds, 0 without it. + int16_t get_buff_value(Buffstat::Id stat) const; // Change a skill void change_skill(int32_t skill_id, int32_t level, int32_t masterlevel, int64_t expiration); diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index ef5dacabc..2920c292a 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -417,6 +417,29 @@ namespace ms if (player.is_dead()) return; + // A Beholder heals its owner when it can, else buffs them, each in + // its skill's time (FUN_007a4080). + for (auto &[oid, summon] : summons.get_local()) + { + if (summon->get_assist() != summon_rules::SUPPORT) + continue; + + if (summon->next_heal < 0) + { + summon->next_heal = now + summon_rules::SUPPORT_DELAY; + summon->next_hex = now + summon_rules::SUPPORT_DELAY; + } + + if (!summon->is_idle()) + continue; + + if (now > summon->next_heal && beholder_heal(oid, *summon)) + continue; + + if (now > summon->next_hex) + beholder_hex(oid, *summon); + } + for (auto &[oid, summon] : summons.get_local()) { int32_t skill = summon->get_skill(); @@ -534,6 +557,70 @@ namespace ms } } + namespace + { + nl::node skill_level(int32_t skill, int32_t level) + { + return nl::nx::skill[string_format::extend_id(skill / 10000, 3) + ".img"]["skill"] + [string_format::extend_id(skill, 7)]["level"][std::to_string(level)]; + } + } + + bool Combat::beholder_heal(int32_t oid, Summon &beholder) + { + SkillBook &skills = player.get_skills(); + int32_t level = skills.get_level(summon_rules::AURA_OF_THE_BEHOLDER); + + if (level <= 0) + return false; + + nl::node aura = skill_level(summon_rules::AURA_OF_THE_BEHOLDER, level); + int32_t berserk_level = skills.get_level(SkillId::Id::BERSERK); + int32_t berserk = berserk_level > 0 + ? static_cast(skill_level(SkillId::Id::BERSERK, berserk_level)["x"].get_integer()) + : 0; + const CharStats &stats = player.get_stats(); + + if (!summon_rules::aura_heals(stats.get_stat(MapleStat::Id::HP), stats.get_total(EquipStat::Id::HP), + static_cast(aura["hp"].get_integer()), berserk)) + return false; + + BeholderPacket(oid, beholder.is_left(), -1).dispatch(); + beholder.next_heal = now + aura["x"].get_integer() * 1000; + beholder.play(summon_rules::SKILL1); + + return true; + } + + void Combat::beholder_hex(int32_t oid, Summon &beholder) + { + int32_t level = player.get_skills().get_level(summon_rules::HEX_OF_THE_BEHOLDER); + + if (level <= 0) + return; + + nl::node hex = skill_level(summon_rules::HEX_OF_THE_BEHOLDER, level); + std::array gives = { + static_cast(hex["pdd"].get_integer()), static_cast(hex["mdd"].get_integer()), + static_cast(hex["acc"].get_integer()), static_cast(hex["eva"].get_integer()), + static_cast(hex["pad"].get_integer()) + }; + std::array has = { + player.get_buff_value(Buffstat::Id::WDEF), player.get_buff_value(Buffstat::Id::MDEF), + player.get_buff_value(Buffstat::Id::ACC), player.get_buff_value(Buffstat::Id::AVOID), + player.get_buff_value(Buffstat::Id::WATK) + }; + Randomizer random; + int8_t hexed = summon_rules::hex_pick(gives, has, random.next_int(UINT32_MAX)); + + if (hexed < 0) + return; + + BeholderPacket(oid, beholder.is_left(), hexed).dispatch(); + beholder.next_hex = now + hex["x"].get_integer() * 1000; + beholder.play(static_cast(summon_rules::SKILL1 + 1 + hexed)); + } + void Combat::apply_summon_hit(const SummonHit &hit) { if (!mobs.contains(hit.oid)) diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index 9175642fb..ef55aa7a6 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -155,6 +155,11 @@ namespace ms // The player's summons attack when they may (FUN_00765047). void update_summons(); + // The player's Beholder heals them if it may (FUN_007a5ab4), and + // whether it did. + bool beholder_heal(int32_t oid, Summon &beholder); + // The player's Beholder buffs a stat of theirs (FUN_007a5c99). + void beholder_hex(int32_t oid, Summon &beholder); // A summon's hit landing on a mob. struct SummonHit diff --git a/HeavenClient/Gameplay/MapleMap/MapSummons.h b/HeavenClient/Gameplay/MapleMap/MapSummons.h index 7b7969c0c..c0c6e5215 100644 --- a/HeavenClient/Gameplay/MapleMap/MapSummons.h +++ b/HeavenClient/Gameplay/MapleMap/MapSummons.h @@ -82,6 +82,10 @@ namespace ms Rectangle get_body() const; // When it last attacked, in the combat's ms. int64_t last_attack = 0; + // When a Beholder may next heal, and buff, its owner, in the + // combat's ms; -1 until its first update. + int64_t next_heal = -1; + int64_t next_hex = -1; void set_left(bool left); diff --git a/HeavenClient/Gameplay/MapleMap/SummonRules.h b/HeavenClient/Gameplay/MapleMap/SummonRules.h index 28488fe85..42c19b516 100644 --- a/HeavenClient/Gameplay/MapleMap/SummonRules.h +++ b/HeavenClient/Gameplay/MapleMap/SummonRules.h @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include #include @@ -31,6 +32,8 @@ namespace ms::summon_rules constexpr int32_t OCTOPUS = 5211001; constexpr int32_t GAVIOTA = 5211002; constexpr int32_t WRATH_OF_THE_OCTOPI = 5220002; + constexpr int32_t AURA_OF_THE_BEHOLDER = 1320008; + constexpr int32_t HEX_OF_THE_BEHOLDER = 1320009; // How a summon moves: its spawn's moveAbility. enum MoveAbility : uint8_t @@ -349,6 +352,77 @@ namespace ms::summon_rules return static_cast(40.0 / (level + 70) * attack * rolled * 0.01); } + // How long a Beholder waits after it comes before it first heals or + // buffs (FUN_007a373a). + constexpr int64_t SUPPORT_DELAY = 10000; + + // Whether the Beholder's Aura heals its owner by `heal` (FUN_007a5ab4): + // they are hurt, and if they know Berserk (`berserk` its x %, else 0) + // and are in it (I: at or under that % of their HP), it wouldn't take + // them out of it. + inline bool aura_heals(int32_t hp, int32_t maxhp, int32_t heal, int32_t berserk) + { + if (hp >= maxhp) + return false; + + if (berserk <= 0) + return true; + + int32_t threshold = maxhp * berserk / 100; + + return hp > threshold || hp + heal <= threshold; + } + + // Hex of the Beholder's stats, in the order its levels add them. + enum HexStat : uint8_t + { + HEX_PDD, + HEX_MDD, + HEX_ACC, + HEX_EVA, + HEX_PAD + }; + + // Which stat Hex buffs (FUN_007a5c99), of those its level gives + // (`hex`, 0 for none): one picked with `roll`, the later ones likelier, + // unless a lower one is short among the owner's buffs (`have`); -1 with + // none. + inline int8_t hex_pick(const std::array &hex, const std::array &have, uint32_t roll) + { + int32_t count = 0; + uint32_t weight = 0; + + for (int32_t i = 0; i < 5; i++) + { + if (hex[i] != 0) + { + count++; + weight += i + 1; + } + } + + if (count == 0) + return -1; + + uint32_t r = roll % weight + 1; + uint32_t reach = 0; + int32_t picked = 0; + + for (; picked < count; picked++) + { + reach += picked + 1; + + if (r <= reach) + break; + } + + for (int32_t i = HEX_PAD; i >= HEX_PDD; i--) + if (hex[i] != 0 && have[i] < hex[i] && picked >= i) + return static_cast(i); + + return static_cast(picked); + } + // The main stat of a summoner's job: STR for warriors, LUK for // thieves, DEX for bowmen and pirates, INT for magicians. enum MainStat diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index cc7c0b2c6..283365603 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -122,6 +122,7 @@ namespace ms MOVE_SUMMON = 175, SUMMON_ATTACK = 176, DAMAGE_SUMMON = 177, + BEHOLDER = 178, MOVE_MONSTER = 188, MOB_DISTANCE = 189, NPC_ACTION = 197, diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 6a3b98625..aeb0b4cc6 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -275,6 +275,24 @@ namespace ms } }; + // The player's Beholder heals them (Aura of the Beholder, `hexed` -1) + // or buffs Hex stat `hexed` (FUN_007a5ab4 / FUN_007a5c99), playing + // skill1, or skill2 on. + // Opcode: BEHOLDER(178) + class BeholderPacket : public OutPacket + { + public: + BeholderPacket(int32_t oid, bool left, int8_t hexed) : OutPacket(OutPacket::Opcode::BEHOLDER) + { + write_int(oid); + write_int(hexed < 0 ? 1320008 : 1320009); + write_byte(static_cast(left << 7 | (hexed < 0 ? 6 : 7 + hexed))); + + if (hexed >= 0) + write_byte(hexed); + } + }; + // Tells the server which mobs Monster Magnet reached: each one's oid, 0 // for one out of sight, and whether it was caught (0x96c62a). It may // reach none. diff --git a/HeavenClient/tests/summon_rules.cpp b/HeavenClient/tests/summon_rules.cpp index 5073c3bbe..a578b943e 100644 --- a/HeavenClient/tests/summon_rules.cpp +++ b/HeavenClient/tests/summon_rules.cpp @@ -96,6 +96,24 @@ int main() require(main_stat(311) == DEX && main_stat(412) == LUK && main_stat(212) == INT && main_stat(1100) == STR && main_stat(520) == DEX, "each job has its main stat"); + // A Beholder heals a hurt owner, but not out of Berserk. + require(!aura_heals(50, 50, 40, 0) && aura_heals(10, 50, 40, 0), "the Aura heals a hurt owner"); + require(aura_heals(400, 1000, 40, 50) && !aura_heals(480, 1000, 40, 50) && aura_heals(600, 1000, 40, 50), + "it keeps a berserk owner berserk"); + + // Hex picks among its stats, the later ones likelier. + std::array none{}; + require(hex_pick({ 20, 0, 0, 0, 0 }, none, 12345) == HEX_PDD, "level 1 raises weapon defense"); + std::array all{ 100, 100, 25, 50, 15 }; + require(hex_pick(all, none, 0) == HEX_PDD && hex_pick(all, none, 1) == HEX_MDD && + hex_pick(all, none, 5) == HEX_ACC && hex_pick(all, none, 6) == HEX_EVA && + hex_pick(all, none, 14) == HEX_PAD && hex_pick(all, none, 15) == HEX_PDD, + "one in 15 weapon defense, five in 15 weapon attack"); + require(hex_pick(all, { 0, 0, 0, 0, 15 }, 14) == HEX_EVA, "a stat the owner has is passed over for a lower one"); + require(hex_pick(all, { 0, 100, 25, 50, 15 }, 3) == HEX_PDD, "down to the lowest they lack"); + require(hex_pick(all, { 100, 100, 25, 50, 15 }, 3) == HEX_ACC, "or the one picked, with all"); + require(hex_pick(none, none, 3) == -1, "no Hex, no buff"); + std::cout << "Summon rules passed\n"; return 0; } diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 99566a00a..79f187b4c 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -428,6 +428,16 @@ the rest: - **DAMAGE_SUMMON** (`0xB1` in, `0xB4` out) hurts a Puppet. Puppets are the only summons that can be hurt, and their HP is the level's `x`. At 0 HP the Puppet breaks: REMOVE_SUMMON reason 2, and the owner's buff ends. +- **BEHOLDER** (`0xB2` in, SUMMON_SKILL `0xB3` out) is a Dark Knight's + Beholder healing its owner with Aura of the Beholder (the level's `hp`) + or buffing one stat with Hex of the Beholder (`summons::support`). Hex's + stats are numbered in the order its levels add them: weapon defense, + magic defense, accuracy, avoidability, weapon attack. Its buff lasts the + level's `time`. The owner must know the skill, and Hex's level must give + the stat. The field allows each no sooner than 10 s after the Beholder + comes, then the level's `x` seconds apart, with 0.5 s of slack for the + client's clock. The others see the Beholder play the action the owner + sent. REMOVE_SUMMON (`0xB0`) reasons: @@ -458,15 +468,29 @@ HeavenClient's `MapSummons` (`Gameplay/MapleMap`) draws them and plays their - Each hit lands `attackAfter` plus the projectile's flight time after the attack starts. +A Puppet draws the mobs already chasing the owner to it for 180–240 s. +A mob touching it hurts it by its attack squared, scaled by the Puppet's +level (`summon_rules::touch_damage`), and the owner sends DAMAGE_SUMMON. + +A Beholder heals its owner when it may and they are hurt, else buffs +them: + +- It first does so 10 s after it comes, then every Aura's or Hex's `x` + seconds. +- In Berserk, it doesn't heal the owner out of it. +- Hex picks one of the stats its level gives. Later stats are likelier: + the n-th of k is picked with weight n out of k(k+1)/2. If the owner's + buffs already give at least that stat, Hex tries the lower stats from + the top for one they lack (`summon_rules::hex_pick`). + Where HeavenClient differs from v83: - Ball sprites aren't drawn. - Followers send their paths the way fliers do. - The fly speed (0.2 px/ms) is inferred. - Range targeting is a ±`r` × ±100 px box. -- Puppets don't draw mobs to them yet, and mobs don't hurt summons - client-side yet. -- Beholder's buffs and heals (`0xB3`) are missing. +- Whether the owner is in Berserk is inferred to be their HP at or under + its `x` percent. `v83ref` report 158 has the client's detail. @@ -564,4 +588,6 @@ checks the cloud's packet, that it lifts on time, and that it poisons only the snails in its box. `summons` has a ranger call up a Silver Hawk and a Puppet, and checks what a watcher sees: the spawns, a refused and an accepted Hawk attack, the Puppet breaking, and the Hawk following its owner to the -next map with no entrance. +next map with no entrance. It also has a Dark Knight's Beholder heal them +and buff their weapon defense, refusing both too soon and a stat Hex's +level doesn't give. diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index 7bd8a36b6..d87430c8d 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -195,6 +195,21 @@ pub fn damage_summon(object_id: i32, damage: i32, mob_id: i32) -> Packet { p } +/// BEHOLDER: Beholder `object_id` healing its owner (Aura of the +/// Beholder), or buffing stat `buff` (Hex of the Beholder), as the client +/// sends it (FUN_007a5ab4 / FUN_007a5c99). +pub fn beholder(object_id: i32, skill_id: i32, buff: Option) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::Beholder as i16).unwrap(); + p.write_int(object_id).unwrap(); + p.write_int(skill_id).unwrap(); + p.write_byte(buff.map_or(6, |buff| 7 + buff)).unwrap(); + if let Some(buff) = buff { + p.write_byte(buff).unwrap(); + } + p +} + /// CANCEL_BUFF: right-clicking a skill's buff icon. pub fn cancel_buff(skill_id: i32) -> Packet { let mut p = Packet::new_empty(); @@ -286,6 +301,8 @@ pub struct Field { pub summons_moved: Vec, /// (object id, action, (mob object id, damage) per hit) pub summon_attacks: Vec<(i32, u8, Vec<(i32, i32)>)>, + /// SUMMON_SKILL: (object id, action). + pub summon_skills: Vec<(i32, u8)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -405,6 +422,10 @@ impl Field { }) .collect(); self.summon_attacks.push((object_id, action, hits)); + } else if op == SendOpcode::SummonSkill as i16 { + r.read_int().unwrap(); + let object_id = r.read_int().unwrap(); + self.summon_skills.push((object_id, r.read_byte().unwrap())); } else if op == SendOpcode::SpawnMonsterControl as i16 { let mode = r.read_byte().unwrap(); let oid = r.read_int().unwrap(); diff --git a/integration-harness/tests/summons.rs b/integration-harness/tests/summons.rs index 8bf4f7b7b..dba512c83 100644 --- a/integration-harness/tests/summons.rs +++ b/integration-harness/tests/summons.rs @@ -1,13 +1,19 @@ //! Summons on Snail Hunting Ground II (40001): a Ranger's Silver Hawk //! and Puppet appear for the map, the Hawk's attacks reach the others //! within its damage cap, a Puppet breaks when hurt past its HP, and the -//! Hawk follows its owner to the next map. +//! Hawk follows its owner to the next map. A Dark Knight's Beholder heals +//! and buffs them, no sooner than its skills allow. use integration_harness::database::{grant_skill, grant_stack, set_job}; -use integration_harness::hunting::{attack, damage_summon, summon, summon_attack, walk_to}; +use integration_harness::hunting::{ + attack, beholder, damage_summon, summon, summon_attack, walk_to, +}; use integration_harness::preconditions::load_harness_config_or_fail; -use integration_harness::trace::{read_for, send, until, warp, Trace}; +use integration_harness::trace::{read_for, send, stat, until, warp, Trace}; use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; use tokio::time::Duration; const HUNTING_GROUND: i32 = 40_001; @@ -18,6 +24,12 @@ const SILVER_HAWK: i32 = 3111005; const PUPPET: i32 = 3111002; const ETC: i16 = 4; const SUMMONING_ROCK: i32 = 4006001; +/// Henesys Hunting Ground I, apart from the ranger's test. +const HENESYS_HUNTING_GROUND: i32 = 104_040_000; +const DARK_KNIGHT: i16 = 132; +const BEHOLDER: i32 = 1321007; +const AURA: i32 = 1320008; +const HEX: i32 = 1320009; #[tokio::test] async fn a_rangers_hawk_attacks_and_follows_and_their_puppet_breaks() { @@ -124,3 +136,86 @@ async fn a_rangers_hawk_attacks_and_follows_and_their_puppet_breaks() { (ranger_id, SILVER_HAWK, false) ); } + +/// TAKE_DAMAGE: a snail's touch. +fn touched(damage: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::TakeDamage as i16).unwrap(); + p.write_int(0).unwrap(); + p.write_byte(-1i8 as u8).unwrap(); + p.write_byte(0).unwrap(); + p.write_int(damage).unwrap(); + p.write_int(SNAIL).unwrap(); + p.write_int(0).unwrap(); + p.write_byte(0).unwrap(); + p +} + +#[tokio::test] +async fn a_dark_knights_beholder_heals_and_hexes_them_in_its_own_time() { + let config = load_harness_config_or_fail().await; + let knight_id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(knight_id, DARK_KNIGHT, 120, 2000) + .await + .expect("make a dark knight"); + // Level 1: Aura heals 40 every 10 s; Hex raises weapon defense by 20 + // for 20 s, every 48 s. + for skill_id in [BEHOLDER, AURA, HEX] { + grant_skill(knight_id, skill_id, 1) + .await + .expect("teach the Beholder"); + } + + let mut knight = relogin(&config).await; + let watcher_config = load_harness_config_or_fail().await; + let mut watcher = login_to_world_session(&watcher_config).await.unwrap(); + let (k, w) = (&mut knight.connection, &mut watcher.connection); + let (mut kt, mut wt) = (Trace::default(), Trace::default()); + let map = HENESYS_HUNTING_GROUND; + assert_eq!(warp(w, map, "", &mut wt).await, map); + assert_eq!(warp(k, map, "", &mut kt).await, map); + send(k, summon(BEHOLDER, 1, 0, 0, false)).await; + until(w, &mut wt, |t| !t.field.summons.is_empty()).await; + let summoned = std::time::Instant::now(); + let eye = wt.field.summons[0]; + assert_eq!( + (eye.owner_id, eye.skill_id, eye.moves, eye.assist), + (knight_id, BEHOLDER, 1, 2) + ); + send(k, touched(12)).await; + until(k, &mut kt, |t| t.stat(stat::HP).is_some()).await; + let hurt = kt.stat(stat::HP).unwrap(); + + // Not in its first 10 s. + send(k, beholder(eye.object_id, AURA, None)).await; + read_for(w, &mut wt, 300).await; + assert!(wt.field.summon_skills.is_empty()); + + tokio::time::sleep(Duration::from_secs(10).saturating_sub(summoned.elapsed())).await; + send(k, beholder(eye.object_id, AURA, None)).await; + until(w, &mut wt, |t| !t.field.summon_skills.is_empty()).await; + until(k, &mut kt, |t| t.stat(stat::HP) != Some(hurt)).await; + assert_eq!(kt.stat(stat::HP), Some(hurt + 12)); + + // Hex level 1 raises only weapon defense, its own time apart. + send(k, beholder(eye.object_id, HEX, Some(4))).await; + send(k, beholder(eye.object_id, HEX, Some(0))).await; + until(k, &mut kt, |t| t.buffs.len() == 2).await; + assert_eq!(kt.buffs[1], (HEX, 20, 20_000)); + + // Neither again so soon. + send(k, beholder(eye.object_id, AURA, None)).await; + send(k, beholder(eye.object_id, HEX, Some(0))).await; + read_for(w, &mut wt, 300).await; + read_for(k, &mut kt, 300).await; + assert_eq!( + wt.field.summon_skills, + vec![(eye.object_id, 6), (eye.object_id, 7)] + ); + assert_eq!(kt.buffs.len(), 2); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 555ca2134..855d1c237 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -444,6 +444,13 @@ pub enum FieldRequest { }, /// A mob hurt the player's Puppet. SummonDamage(crate::summons::SummonDamage), + /// The player's Beholder heals or buffs them, if it is theirs and not + /// too soon; the others see it play `action`. + Beholder { + object_id: i32, + action: u8, + support: crate::summons::Support, + }, } impl ReplyAction for WorldAction { @@ -554,6 +561,7 @@ fn world_routes() -> &'static HashMap { (Op::MoveSummon, InGame(&world::MoveSummonHandler)), (Op::SummonAttack, InGame(&world::SummonAttackHandler)), (Op::DamageSummon, InGame(&world::DamageSummonHandler)), + (Op::Beholder, InGame(&world::BeholderHandler)), ]; table .into_iter() @@ -584,7 +592,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 52); + assert_eq!(world_routes().len(), 53); } #[test] diff --git a/net/src/packet/build/world/summon.rs b/net/src/packet/build/world/summon.rs index 7433935b6..b7f5469fa 100644 --- a/net/src/packet/build/world/summon.rs +++ b/net/src/packet/build/world/summon.rs @@ -126,6 +126,21 @@ pub fn build_damage_summon( Ok(p) } +/// SUMMON_SKILL (0xB3, FUN_007a6e5a): a Beholder heals or buffs its +/// owner, playing `action` (its facing bit is ignored). +pub fn build_summon_skill( + owner_id: i32, + object_id: i32, + action: u8, +) -> Result { + let mut p = Packet::new_empty(); + p.write_short(SendOpcode::SummonSkill as i16)?; + p.write_int(owner_id)?; + p.write_int(object_id)?; + p.write_byte(action)?; + Ok(p) +} + #[cfg(test)] mod tests { use super::*; @@ -214,4 +229,13 @@ mod tests { want.push(0); assert_eq!(p.bytes, want); } + + #[test] + fn a_beholder_acts_for_the_others() { + let p = build_summon_skill(7, 900_000_000, 0x86).unwrap(); + let mut want = vec![0xB3, 0x00]; + want.extend(ints(&[7, 900_000_000])); + want.push(0x86); + assert_eq!(p.bytes, want); + } } diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index e97ab7fa9..14bef3def 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -61,7 +61,9 @@ pub use self::reactor::DamageReactorHandler; pub use self::rearrange::{GatherItemsHandler, SortItemsHandler}; pub use self::script_portal::ChangeMapSpecialHandler; pub use self::spend_points::{SpendApHandler, SpendSpHandler}; -pub use self::summon::{DamageSummonHandler, MoveSummonHandler, SummonAttackHandler}; +pub use self::summon::{ + BeholderHandler, DamageSummonHandler, MoveSummonHandler, SummonAttackHandler, +}; pub use self::use_item::{HealOverTimeHandler, UseItemHandler, UseSkillBookHandler}; pub use self::use_skill::{ party_skill_reached, CancelBuffHandler, SkillEffectHandler, ThrowGrenadeHandler, diff --git a/net/src/packet/handle/world/summon.rs b/net/src/packet/handle/world/summon.rs index a36b8cd62..283ff234a 100644 --- a/net/src/packet/handle/world/summon.rs +++ b/net/src/packet/handle/world/summon.rs @@ -61,6 +61,27 @@ impl WorldHandler for SummonAttackHandler { } } +/// BEHOLDER (0xB2): the player's Beholder heals or buffs them. The field +/// checks it is theirs and holds it to its skill's pace. +pub struct BeholderHandler; + +impl WorldHandler for BeholderHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + let act = summons::parse_beholder(packet)?; + let Some(support) = summons::support(ctx.player, &act) else { + debug!(?act, "Beholder refused"); + return Ok(HandlerResult::empty()); + }; + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::Beholder { + object_id: act.object_id, + action: act.action, + support, + })), + ) + } +} + /// DAMAGE_SUMMON (0xB1): a mob hurt the player's Puppet. pub struct DamageSummonHandler; diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 3174b2476..c7afb7d61 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -71,6 +71,7 @@ pub enum RecvOpcode { MoveSummon = 0xAF, SummonAttack = 0xB0, DamageSummon = 0xB1, + Beholder = 0xB2, MoveLife = 0xBC, MobDistance = 0xBD, NpcAction = 0xC5, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index fcb901a24..0a44c90e8 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -47,6 +47,7 @@ pub enum SendOpcode { RemoveSummon = 0xB0, MoveSummon = 0xB1, SummonAttack = 0xB2, + SummonSkill = 0xB3, DamageSummon = 0xB4, ShowItemGainInChat = 0xCE, UpdateQuestInfo = 0xD3, diff --git a/net/src/summons.rs b/net/src/summons.rs index 40748ae6e..f99bd2696 100644 --- a/net/src/summons.rs +++ b/net/src/summons.rs @@ -8,12 +8,15 @@ use crate::skills::{self, Learned}; use game::{BuffStat, InventoryType, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; +use std::time::Duration; pub const PUPPETS: [i32; 3] = [3111002, 3211002, 13111004]; pub const OCTOPUS: i32 = 5211001; pub const GAVIOTA: i32 = 5211002; pub const WRATH_OF_THE_OCTOPI: i32 = 5220002; pub const BEHOLDER: i32 = 1321007; +pub const AURA_OF_THE_BEHOLDER: i32 = 1320008; +pub const HEX_OF_THE_BEHOLDER: i32 = 1320009; /// How a summon moves (its spawn's moveAbility, v83 CVecCtrlSummoned). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -252,6 +255,111 @@ pub fn parse_damage(packet: &Packet) -> Result { }) } +/// BEHOLDER (recv 0xB2, FUN_007a5ab4 / FUN_007a5c99): the player's +/// Beholder heals them (Aura) or buffs them (Hex, one of its stats). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BeholderAct { + pub object_id: i32, + pub skill_id: i32, + /// `left << 7 | animation`: 6 for the heal, 7 + the stat for the buff. + pub action: u8, + /// Hex's stat, 0 to 4. + pub buff: Option, +} + +pub fn parse_beholder(packet: &Packet) -> Result { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let object_id = r.read_int()?; + let skill_id = r.read_int()?; + let action = r.read_byte()?; + let buff = if skill_id == HEX_OF_THE_BEHOLDER { + Some(r.read_byte()?) + } else { + None + }; + Ok(BeholderAct { + object_id, + skill_id, + action, + buff, + }) +} + +/// What a Beholder does for its owner, at most once every `every`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Support { + /// Aura of the Beholder: heal `hp`. + Heal { hp: i32, every: Duration }, + /// Hex of the Beholder: one stat for `lasts`. + Hex { + stat: BuffStat, + value: i16, + lasts: Duration, + every: Duration, + }, +} + +impl Support { + pub fn skill_id(&self) -> i32 { + match self { + Support::Heal { .. } => AURA_OF_THE_BEHOLDER, + Support::Hex { .. } => HEX_OF_THE_BEHOLDER, + } + } + + pub fn every(&self) -> Duration { + match *self { + Support::Heal { every, .. } | Support::Hex { every, .. } => every, + } + } +} + +/// Hex's stats in the order its levels add them, which is how the client +/// numbers them (FUN_007a5c99, level data +0x28, +0x40, +0x4c, +0x58, +/// +0x1c). +const HEX_STATS: [BuffStat; 5] = [ + BuffStat::Pdd, + BuffStat::Mdd, + BuffStat::Acc, + BuffStat::Eva, + BuffStat::Pad, +]; + +/// What the player's Beholder may do for them: an Aura or Hex they know, +/// and for Hex a stat its level gives. Nothing while they are dead. +pub fn support(player: &Player, act: &BeholderAct) -> Option { + if player.stats.hp <= 0 || player.skills.level(BEHOLDER) <= 0 { + return None; + } + let skill = skills::learned(player, act.skill_id).filter(|skill| skill.level > 0)?; + let effect = &skill.effect; + let every = Duration::from_secs(effect.x.max(0) as u64); + match act.skill_id { + AURA_OF_THE_BEHOLDER => Some(Support::Heal { + hp: effect.hp, + every, + }), + HEX_OF_THE_BEHOLDER => { + let stat = *HEX_STATS.get(usize::from(act.buff?))?; + let value = match stat { + BuffStat::Pdd => effect.pdd, + BuffStat::Mdd => effect.mdd, + BuffStat::Acc => effect.acc, + BuffStat::Eva => effect.eva, + _ => effect.pad, + }; + (value > 0).then(|| Support::Hex { + stat, + value: value as i16, + lasts: Duration::from_secs(effect.duration.max(0) as u64), + every, + }) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -352,4 +460,87 @@ mod tests { assert_eq!(damage.damage, 55); assert_eq!(damage.mob, Some((100100, true))); } + + fn beholder(skill_id: i32, action: u8, buff: Option) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(0xB2).unwrap(); + p.write_int(900).unwrap(); + p.write_int(skill_id).unwrap(); + p.write_byte(action).unwrap(); + if let Some(buff) = buff { + p.write_byte(buff).unwrap(); + } + p + } + + #[test] + fn a_beholder_names_its_skill_and_hex_its_stat() { + assert_eq!( + parse_beholder(&beholder(AURA_OF_THE_BEHOLDER, 0x86, None)).unwrap(), + BeholderAct { + object_id: 900, + skill_id: AURA_OF_THE_BEHOLDER, + action: 0x86, + buff: None, + } + ); + let hex = parse_beholder(&beholder(HEX_OF_THE_BEHOLDER, 11, Some(4))).unwrap(); + assert_eq!((hex.action, hex.buff), (11, Some(4))); + } + + #[test] + fn a_beholder_heals_and_hexes_by_what_its_owner_knows() { + let aura = parse_beholder(&beholder(AURA_OF_THE_BEHOLDER, 6, None)).unwrap(); + let hex = + |stat| parse_beholder(&beholder(HEX_OF_THE_BEHOLDER, 7 + stat, Some(stat))).unwrap(); + assert_eq!(support(&knowing(&[(AURA_OF_THE_BEHOLDER, 1)]), &aura), None); + + let player = knowing(&[ + (BEHOLDER, 1), + (AURA_OF_THE_BEHOLDER, 1), + (HEX_OF_THE_BEHOLDER, 1), + ]); + assert_eq!( + support(&player, &aura), + Some(Support::Heal { + hp: 40, + every: Duration::from_secs(10), + }) + ); + // Level 1 Hex raises only weapon defense. + assert_eq!( + support(&player, &hex(0)), + Some(Support::Hex { + stat: BuffStat::Pdd, + value: 20, + lasts: Duration::from_secs(20), + every: Duration::from_secs(48), + }) + ); + assert_eq!(support(&player, &hex(4)), None); + assert_eq!(support(&player, &hex(5)), None); + + let player = knowing(&[(BEHOLDER, 1), (HEX_OF_THE_BEHOLDER, 25)]); + assert_eq!(support(&player, &aura), None); + let stats: Vec<_> = (0..5) + .map(|stat| match support(&player, &hex(stat)) { + Some(Support::Hex { stat, value, .. }) => (stat, value), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + stats, + vec![ + (BuffStat::Pdd, 100), + (BuffStat::Mdd, 100), + (BuffStat::Acc, 25), + (BuffStat::Eva, 50), + (BuffStat::Pad, 15), + ] + ); + + let mut dead = player; + dead.stats.hp = 0; + assert_eq!(support(&dead, &hex(0)), None); + } } diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index db5ccfdd9..f9fe4ba17 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -946,6 +946,7 @@ impl ClientActor { } => self.take_fame(giver, giver_name, up).await?, ServerMessage::FameMissed { target } => self.fame_missed(target).await?, ServerMessage::SummonBroken { skill_id } => self.summon_broken(skill_id).await?, + ServerMessage::Beholder(support) => self.beholder_support(support).await?, ServerMessage::SendPacket(mut packet) => self.send(&mut packet).await?, } Ok(()) diff --git a/runtime/src/actor/client/buff.rs b/runtime/src/actor/client/buff.rs index 05d39abb2..a5032402e 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -1,11 +1,13 @@ use super::ClientActor; use crate::error::RuntimeError; use crate::message::FieldMessage; +use game::buff::Buff; use game::BuffStat; use net::packet::build::world::buff; use net::packet::build::world::stat::{self, StatUpdate}; use net::packet::build::world::summon::Gone; use net::packet::handle::world::party_skill_reached; +use net::summons::Support; use net::{summons, FieldRequest, PartySkill, SpawnPosition}; use packet::Packet; @@ -133,6 +135,32 @@ impl ClientActor { self.end_buff(skill_id).await } + /// The player's Beholder heals them, or buffs one stat, while they + /// live. + pub(super) async fn beholder_support(&mut self, support: Support) -> Result<(), RuntimeError> { + let player = &mut self.in_game_mut()?.player; + if player.stats.hp <= 0 { + return Ok(()); + } + let mut packet = match support { + Support::Heal { hp, .. } => { + let hp = player.add_hp(hp); + stat::build_stat_update(false, &[StatUpdate::Hp(hp)])? + } + Support::Hex { + stat, value, lasts, .. + } => { + let now = std::time::Instant::now(); + let skill_id = support.skill_id(); + player + .buffs + .give(Buff::new(skill_id, stat, value, now, lasts)); + buff::build_give_buff(skill_id, lasts.as_millis() as i32, &[(stat, value)])? + } + }; + self.send(&mut packet).await + } + /// On a new map the summons that follow the player come along, at /// `spawn`; those that stay where they were put are gone. pub(super) async fn bring_summons(&mut self, spawn: SpawnPosition) -> Result<(), RuntimeError> { diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index d00646308..6b9b5a901 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -23,17 +23,17 @@ use net::packet::build::world::reactor::{ }; use net::packet::build::world::summon::{ build_damage_summon, build_move_summon, build_remove_summon, build_spawn_summon, - build_summon_attack, Gone, SummonView, + build_summon_attack, build_summon_skill, Gone, SummonView, }; use net::skills::{self, Inflict}; -use net::summons::{self, Limit, SummonAttack, SummonDamage}; +use net::summons::{self, Limit, SummonAttack, SummonDamage, Support}; use net::{ClientId, FieldRequest}; use packet::Packet; use std::collections::HashMap; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::{interval, Instant, MissedTickBehavior}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; struct Occupant { handle: ClientHandle, @@ -374,6 +374,11 @@ impl FieldActor { self.handle_summon_attack(from, attack, &limits) } FieldRequest::SummonDamage(damage) => self.handle_summon_damage(from, damage), + FieldRequest::Beholder { + object_id, + action, + support, + } => self.handle_beholder(from, object_id, action, support), }, FieldMessage::SpawnLoot { owner, @@ -844,6 +849,25 @@ impl FieldActor { } } + /// `from`'s Beholder heals or buffs them, if it may yet: the others see + /// it act. + fn handle_beholder(&mut self, from: ClientId, object_id: i32, action: u8, support: Support) { + let Some(summon) = self.summons.owned(from, object_id) else { + return; + }; + if !summon.may_support(&support, Instant::now()) { + debug!(object_id, ?support, "Beholder too soon"); + return; + } + let owner_id = summon.view.owner_id; + if let Ok(packet) = build_summon_skill(owner_id, object_id, action) { + self.broadcast(Some(from), packet); + } + if let Some(owner) = self.occupants.get(&from) { + owner.handle.send(ServerMessage::Beholder(support)); + } + } + /// Each mist due puts its statuses, with its chance, on the live mobs /// in it that lack them (OdinMS-family servers). fn reach_from_mists(&mut self, now: Instant) { diff --git a/runtime/src/actor/field_summons.rs b/runtime/src/actor/field_summons.rs index 632258387..991a9c09b 100644 --- a/runtime/src/actor/field_summons.rs +++ b/runtime/src/actor/field_summons.rs @@ -3,7 +3,7 @@ use crate::actor::object_ids::{self, ObjectIds}; use net::packet::build::world::summon::SummonView; -use net::summons; +use net::summons::{self, Support}; use net::ClientId; use std::collections::BTreeMap; use tokio::time::{Duration, Instant}; @@ -14,6 +14,32 @@ pub struct Summon { /// A Puppet's HP; the others can't be hurt. pub hp: Option, until: Instant, + /// When a Beholder may next heal, and next buff, its owner: 10 s after + /// it comes, then each skill's `x` s after the last (FUN_007a373a). + next_heal: Instant, + next_hex: Instant, +} + +/// How much sooner than due a Beholder's owner may ask, for their clock. +const SUPPORT_SLACK: Duration = Duration::from_millis(500); + +impl Summon { + /// Whether this Beholder may give `support` at `now`; if so, it next + /// may in that skill's time. + pub fn may_support(&mut self, support: &Support, now: Instant) -> bool { + if self.view.skill_id != summons::BEHOLDER { + return false; + } + let next = match support { + Support::Heal { .. } => &mut self.next_heal, + Support::Hex { .. } => &mut self.next_hex, + }; + if now + SUPPORT_SLACK < *next { + return false; + } + *next = now + support.every(); + true + } } pub struct SummonSet { @@ -57,6 +83,8 @@ impl SummonSet { owner, hp, until: now + lasts, + next_heal: now + Duration::from_secs(10), + next_hex: now + Duration::from_secs(10), }, ); (view, replaced) @@ -174,4 +202,40 @@ mod tests { assert_eq!(set.remove_owned(1, None).len(), 1); assert_eq!(set.views().count(), 1); } + + #[test] + fn a_beholder_heals_and_buffs_at_its_skills_pace() { + let mut set = SummonSet::default(); + let now = Instant::now(); + let lasts = Duration::from_secs(600); + let (beholder, _) = set.add(view(7, summons::BEHOLDER), 1, None, lasts, now); + let (puppet, _) = set.add(view(7, PUPPET), 1, Some(4000), lasts, now); + let heal = Support::Heal { + hp: 40, + every: Duration::from_secs(10), + }; + let hex = Support::Hex { + stat: game::buff::BuffStat::Pdd, + value: 20, + lasts: Duration::from_secs(20), + every: Duration::from_secs(48), + }; + let at = |secs: f64| now + Duration::from_secs_f64(secs); + + assert!(!set + .owned(1, puppet.object_id) + .unwrap() + .may_support(&heal, at(20.0))); + let summon = set.owned(1, beholder.object_id).unwrap(); + assert!(!summon.may_support(&heal, at(9.0)), "not in its first 10 s"); + assert!( + summon.may_support(&heal, at(9.6)), + "a little early is the owner's clock" + ); + assert!(!summon.may_support(&heal, at(15.0))); + assert!(summon.may_support(&hex, at(15.0)), "each has its own time"); + assert!(summon.may_support(&heal, at(19.6))); + assert!(!summon.may_support(&hex, at(60.0))); + assert!(summon.may_support(&hex, at(63.0))); + } } diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 203354839..4ca292e53 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -116,6 +116,8 @@ pub enum ServerMessage { SummonBroken { skill_id: i32, }, + /// This player's Beholder heals or buffs them. + Beholder(net::summons::Support), SendPacket(Packet), } From e1f498084d4474c72042307de533c0b4a6df79ea Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 11:07:24 -0400 Subject: [PATCH 4/4] L158 B: Silver Hawk stuns and Elquines freezes the mobs they hit A summon's hits now carry its skill's status, as a player's attacks do. Silver Hawk and Golden Eagle stun at the level's prop (50% to 99%). Elquines and Frostprey always freeze; their data has no prop. Each status lasts the level's x seconds, inferred from the freezes' x growing from 1 to 3. The client only shows the status it is sent. The harness has an Arch Mage's Elquines freeze a mob for 3 s. The Beholder test now finds its own summon on the shared map. --- agent_docs/combat.md | 10 +++- integration-harness/tests/summons.rs | 79 ++++++++++++++++++++++++++-- net/src/summons.rs | 65 ++++++++++++++++++++--- runtime/src/actor/field.rs | 3 +- 4 files changed, 144 insertions(+), 13 deletions(-) diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 79f187b4c..182be94a2 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -425,6 +425,14 @@ the rest: formula for a magic one, times the level's `pad` or `mad` percent. Otherwise the hits go through the field's usual damage and kill path. + A hit that does damage can also put a status on its mob + (`summons::inflict`): + - Silver Hawk and Golden Eagle stun at the level's `prop`. + - Elquines and Frostprey always freeze; their data has no `prop`. + + Each status lasts the level's `x` seconds. That is inferred: the client + only shows what it's sent, and OdinMS-family servers hold every summon's + status for 4 s. - **DAMAGE_SUMMON** (`0xB1` in, `0xB4` out) hurts a Puppet. Puppets are the only summons that can be hurt, and their HP is the level's `x`. At 0 HP the Puppet breaks: REMOVE_SUMMON reason 2, and the owner's buff ends. @@ -590,4 +598,4 @@ Puppet, and checks what a watcher sees: the spawns, a refused and an accepted Hawk attack, the Puppet breaking, and the Hawk following its owner to the next map with no entrance. It also has a Dark Knight's Beholder heal them and buff their weapon defense, refusing both too soon and a stat Hex's -level doesn't give. +level doesn't give, and an Arch Mage's Elquines freeze a mob for 3 s. diff --git a/integration-harness/tests/summons.rs b/integration-harness/tests/summons.rs index dba512c83..d1ac833fe 100644 --- a/integration-harness/tests/summons.rs +++ b/integration-harness/tests/summons.rs @@ -2,7 +2,8 @@ //! and Puppet appear for the map, the Hawk's attacks reach the others //! within its damage cap, a Puppet breaks when hurt past its HP, and the //! Hawk follows its owner to the next map. A Dark Knight's Beholder heals -//! and buffs them, no sooner than its skills allow. +//! and buffs them, no sooner than its skills allow, and a Fire/Poison Arch +//! Mage's Elquines freezes what it hits. use integration_harness::database::{grant_skill, grant_stack, set_job}; use integration_harness::hunting::{ @@ -30,6 +31,9 @@ const DARK_KNIGHT: i16 = 132; const BEHOLDER: i32 = 1321007; const AURA: i32 = 1320008; const HEX: i32 = 1320009; +const FP_ARCH_MAGE: i16 = 212; +const ELQUINES: i32 = 2121005; +const FREEZE: u128 = 1 << 8; #[tokio::test] async fn a_rangers_hawk_attacks_and_follows_and_their_puppet_breaks() { @@ -180,9 +184,17 @@ async fn a_dark_knights_beholder_heals_and_hexes_them_in_its_own_time() { assert_eq!(warp(w, map, "", &mut wt).await, map); assert_eq!(warp(k, map, "", &mut kt).await, map); send(k, summon(BEHOLDER, 1, 0, 0, false)).await; - until(w, &mut wt, |t| !t.field.summons.is_empty()).await; + // The arch mage's test shares the map. + let mine = |t: &Trace| { + t.field + .summons + .iter() + .find(|s| s.owner_id == knight_id) + .copied() + }; + until(w, &mut wt, |t| mine(t).is_some()).await; let summoned = std::time::Instant::now(); - let eye = wt.field.summons[0]; + let eye = mine(&wt).unwrap(); assert_eq!( (eye.owner_id, eye.skill_id, eye.moves, eye.assist), (knight_id, BEHOLDER, 1, 2) @@ -219,3 +231,64 @@ async fn a_dark_knights_beholder_heals_and_hexes_them_in_its_own_time() { ); assert_eq!(kt.buffs.len(), 2); } + +#[tokio::test] +async fn an_arch_mages_elquines_freezes_what_it_hits() { + let config = load_harness_config_or_fail().await; + let mage_id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + drop(relogin(&config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(mage_id, FP_ARCH_MAGE, 120, 2000) + .await + .expect("make an arch mage"); + // Level 30: a freeze each hit, for 3 seconds. + grant_skill(mage_id, ELQUINES, 30) + .await + .expect("teach Elquines"); + + let mut mage = relogin(&config).await; + let watcher_config = load_harness_config_or_fail().await; + let mut watcher = login_to_world_session(&watcher_config).await.unwrap(); + let (m, w) = (&mut mage.connection, &mut watcher.connection); + let (mut mt, mut wt) = (Trace::default(), Trace::default()); + let map = HENESYS_HUNTING_GROUND; + assert_eq!(warp(w, map, "", &mut wt).await, map); + assert_eq!(warp(m, map, "", &mut mt).await, map); + until(m, &mut mt, |t| !t.field.mobs.is_empty()).await; + let (mob, mob_id) = mt.field.mobs[0]; + let (x, y) = mt.field.mob_positions[&mob]; + send(m, walk_to(x, y)).await; + send(m, summon(ELQUINES, 30, x, y, false)).await; + let mine = |t: &Trace| { + t.field + .summons + .iter() + .find(|s| s.owner_id == mage_id) + .copied() + }; + until(w, &mut wt, |t| mine(t).is_some()).await; + let elquines = mine(&wt).unwrap(); + + // Others' freezes on the map aren't this one's. + let frozen = |t: &Trace| { + t.field + .stat_sets + .iter() + .filter(|&&(oid, ..)| oid == mob) + .copied() + .collect::>() + }; + send(m, summon_attack(elquines.object_id, mob, mob_id, 1)).await; + until(w, &mut wt, |t| !frozen(t).is_empty()).await; + let at = std::time::Instant::now(); + assert_eq!(frozen(&wt), [(mob, FREEZE, 1, ELQUINES)]); + until(w, &mut wt, |t| t.field.stat_resets.contains(&(mob, FREEZE))).await; + let thawed = at.elapsed(); + assert!( + (Duration::from_millis(2_500)..Duration::from_millis(4_500)).contains(&thawed), + "thawed after {thawed:?}" + ); +} diff --git a/net/src/summons.rs b/net/src/summons.rs index f99bd2696..ef2465048 100644 --- a/net/src/summons.rs +++ b/net/src/summons.rs @@ -4,8 +4,8 @@ //! the server shows the others and deals the damage. use crate::error::NetworkError; -use crate::skills::{self, Learned}; -use game::{BuffStat, InventoryType, Player}; +use crate::skills::{self, Amount, Inflict, Learned}; +use game::{BuffStat, InventoryType, MobStat, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use std::time::Duration; @@ -144,21 +144,36 @@ pub fn max_damage(player: &Player, skill: &Learned) -> i32 { ((roll / 100.0 * 2.0).ceil() as i32).max(10) } -/// How often a summon may attack (FUN_00765047). -pub fn attack_interval(skill_id: i32) -> std::time::Duration { - std::time::Duration::from_millis(match skill_id { - WRATH_OF_THE_OCTOPI | GAVIOTA => 1500, - _ => 3000, +/// What a summon's hits put on the mobs: Silver Hawk's and Golden Eagle's +/// stun, at their `prop` (50% to 99%), and Elquines' and Frostprey's +/// freeze, always (their data has no `prop`). The client shows only what +/// it's sent and v83's server isn't available, so each lasts the skill's +/// `x` seconds, the value that grows from 1 to 3 on the freezes +/// (inferred; OdinMS-family servers hold every summon's status 4 s). +pub fn inflict(skill: &Learned) -> Option { + let stat = match skill.skill_id { + 3111005 | 3211005 => MobStat::Stun, + 2121005 | 3221005 => MobStat::Freeze, + _ => return None, + }; + let effect = &skill.effect; + Some(Inflict { + skill_id: skill.skill_id, + prop: if effect.prop > 0 { effect.prop } else { 100 }, + duration: Duration::from_secs(effect.x.max(1) as u64), + statuses: vec![(stat, Amount::Value(1))], }) } /// What the owner's summons may do in one attack, for each summon skill /// they have running: the field checks the attack against its summon's. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct Limit { pub skill_id: i32, pub mob_count: i32, pub max_damage: i32, + /// What its hits put on the mobs. + pub inflict: Option, } /// The limits for each summon the player has up. @@ -173,6 +188,7 @@ pub fn limits(player: &Player) -> Vec { skill_id: skill.skill_id, mob_count: mob_count(&skill), max_damage: max_damage(player, &skill), + inflict: inflict(&skill), }) .collect() } @@ -420,6 +436,39 @@ mod tests { assert_eq!(limits[0].mob_count, 1); } + #[test] + fn hawks_stun_and_the_ice_summons_freeze() { + let player = knowing(&[ + (3111005, 1), + (3211005, 30), + (2121005, 30), + (3221005, 1), + (3121006, 30), + ]); + let inflict = |skill_id| inflict(&skills::learned(&player, skill_id).unwrap()); + let hawk = inflict(3111005).unwrap(); + assert_eq!( + (hawk.prop, hawk.duration, hawk.statuses), + ( + 50, + Duration::from_secs(1), + vec![(MobStat::Stun, Amount::Value(1))] + ) + ); + assert_eq!(inflict(3211005).unwrap().prop, 99); + let elquines = inflict(2121005).unwrap(); + assert_eq!( + (elquines.prop, elquines.duration, elquines.statuses), + ( + 100, + Duration::from_secs(3), + vec![(MobStat::Freeze, Amount::Value(1))] + ) + ); + assert_eq!(inflict(3221005).unwrap().duration, Duration::from_secs(1)); + assert_eq!(inflict(3121006), None); + } + #[test] fn a_summon_attack_names_each_mob_and_its_damage() { let mut p = Packet::new_empty(); diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 6b9b5a901..0f33b84ce 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -820,7 +820,7 @@ impl FieldActor { .iter() .map(|hit| (hit.object_id, i64::from(hit.damage))) .collect(); - self.handle_attack(from, broadcast, hits, &[]); + self.handle_attack(from, broadcast, hits, limit.inflict.as_slice()); } /// A mob hurt `from`'s Puppet: the others see it, and it breaks at @@ -1844,6 +1844,7 @@ mod tests { skill_id: hawk, mob_count: 1, max_damage: 100, + inflict: None, }], }, };