diff --git a/HeavenClient/Character/Buff.cpp b/HeavenClient/Character/Buff.cpp index 7b94eca39..6a423e323 100644 --- a/HeavenClient/Character/Buff.cpp +++ b/HeavenClient/Character/Buff.cpp @@ -76,6 +76,9 @@ namespace ms case 47: return ECHO_OF_HERO; case 49: return GHOST_MORPH; case 51: return CONFUSE; + case 75: return STOP_POTION; + case 76: return STOP_MOTION; + case 77: return FEAR; case 82: return ENERGY_CHARGE; case 83: return DASH2; case 84: return DASH; diff --git a/HeavenClient/Character/Buff.h b/HeavenClient/Character/Buff.h index 35b9a837d..1e0d80da8 100644 --- a/HeavenClient/Character/Buff.h +++ b/HeavenClient/Character/Buff.h @@ -96,6 +96,12 @@ namespace ms CURSE, SLOW, SEDUCE, + // A mob's Undead shares Puppet's bit (46); its reason tells them + // apart. + UNDEAD, + STOP_POTION, + STOP_MOTION, + FEAR, LENGTH }; diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index 9c9659c9a..784a83dfa 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -634,6 +634,11 @@ namespace ms return phobj; } + const PhysicsObject &Char::get_phobj() const + { + return phobj; + } + void Char::init() { CharLook::init(); diff --git a/HeavenClient/Character/Char.h b/HeavenClient/Character/Char.h index fdb07ed2e..e277047f5 100644 --- a/HeavenClient/Character/Char.h +++ b/HeavenClient/Character/Char.h @@ -244,6 +244,7 @@ namespace ms // Return a reference to this characters's physics. PhysicsObject &get_phobj(); + const PhysicsObject &get_phobj() const; // Initialize character effects static void init(); diff --git a/HeavenClient/Character/Diseases.h b/HeavenClient/Character/Diseases.h index a735919a9..d2cdd59ef 100644 --- a/HeavenClient/Character/Diseases.h +++ b/HeavenClient/Character/Diseases.h @@ -13,6 +13,11 @@ namespace ms namespace disease { constexpr int32_t DISPEL = 2311001; + constexpr int32_t UNDEAD = 133; + + // Under Stop Motion, the ms the player may stand still between hurts + // (0x94b1d7). + constexpr int64_t STILL_TIME = 2000; // The mob skill a disease's reason names, and its level. constexpr int32_t skill(int32_t reason) @@ -28,7 +33,8 @@ namespace ms // The stats a mob skill's disease sets. constexpr Buffstat::Id STATS[] = { Buffstat::STUN, Buffstat::POISON, Buffstat::SEAL, Buffstat::DARKNESS, Buffstat::WEAKEN, - Buffstat::CURSE, Buffstat::SLOW, Buffstat::SEDUCE, Buffstat::CONFUSE + Buffstat::CURSE, Buffstat::SLOW, Buffstat::SEDUCE, Buffstat::CONFUSE, Buffstat::UNDEAD, + Buffstat::STOP_POTION, Buffstat::STOP_MOTION, Buffstat::FEAR }; constexpr bool is_disease(Buffstat::Id stat) @@ -40,6 +46,16 @@ namespace ms return false; } + // The stat a buff's bit gives for `reason`: bit 46 is Puppet's, or + // Undead's when a mob skill gave it (CTS 46 in both). + constexpr Buffstat::Id given(Buffstat::Id stat, int32_t reason) + { + if (stat == Buffstat::PUPPET && skill(reason) == UNDEAD && level(reason) > 0) + return Buffstat::UNDEAD; + + return stat; + } + // Which diseases are on the player, with the n of the ones it // matters for. struct State @@ -48,7 +64,10 @@ namespace ms bool sealed = false; bool weakened = false; bool confused = false; + bool stop_potion = false; std::optional seduced; + // Stop Motion's n: the HP standing still costs. + std::optional stop_motion; }; // One frame's arrow input: x is -1 left or 1 right, y -1 up or 1 @@ -117,6 +136,21 @@ namespace ms return can_attack(state) && (!state.sealed || skillid == DISPEL); } + // Items are refused, without a word, under Stop Potion or a + // seduction (FUN_0094fa45, from the quickslots at 0x94f86c and the + // USE_ITEM sender FUN_00a092fb). + constexpr bool can_use_item(const State &state) + { + return !state.stop_potion && !state.seduced; + } + + // What Stop Motion takes from a player with `hp` who stood still: + // its n, but never the last HP (0x94b1d7). + constexpr int32_t stop_motion_damage(int32_t n, int32_t hp) + { + return n < hp - 1 ? n : (hp > 1 ? hp - 1 : 0); + } + // The speed the player walks at: Slow's n in place of their own // (0x92ef56). constexpr int32_t walk_speed(int32_t speed, std::optional slow) @@ -125,8 +159,8 @@ namespace ms } // What the player is told when a mob skill gives them a disease - // (FUN_00932da6, StringPool 0xb5a-0xb5f and 0xec2). Slow and Confuse - // say nothing. + // (FUN_00932da6, StringPool 0xb5a-0xb5f, 0xec2, 0xec3 and + // 0x1558-0x155a). Slow and Confuse say nothing. inline std::string notice(int32_t mobskill) { switch (mobskill) @@ -138,8 +172,16 @@ namespace ms case 124: return "You've been cursed, so the amount of EXP you'll receive will decrease."; case 125: return "You have been poisoned, so your HP will slowly decrease."; case 128: return "You have been seduced, which disables you from moving."; + case 133: return "You will become an Undead and the effects of your Recovery will diminish by 50%."; + case 134: return "Your current condition will not permit you to use a potion."; + case 135: return "You will receive damage if you become covered with the Shadow of Darkness and cannot move."; + case 136: return "The fear of darkness has overtaken you! Your range of vision has been narrowed."; default: return ""; } } + + // What the player is told when Stop Motion hurts them (StringPool + // 0x1561, chat type 7). + constexpr const char *STOP_MOTION_HURT = "You receive damage having been covered by the Shadow of Darkness."; } } diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index b2b3488e1..364df00cc 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -112,6 +112,7 @@ namespace ms set_chair(0); nullstate.update_state(*this); path.reset(current_move(0)); + stirred = now_ms(); } void Player::send_action(KeyAction::Id action, bool down) @@ -122,6 +123,16 @@ namespace ms if (pst && !refused) pst->send_action(*this, action, down); + // Under Stop Motion, a walking key going down, repeating or up + // (FUN_0094c856, after Confuse swaps them), or a jump, is moving. + bool confused = has_buff(Buffstat::Id::CONFUSE); + bool walks = action == KeyAction::Id::LEFT || action == KeyAction::Id::RIGHT || + action == (confused ? KeyAction::Id::UP : KeyAction::Id::DOWN); + + if (has_buff(Buffstat::Id::STOP_MOTION) && !is_dead() && + (walks || (action == KeyAction::Id::JUMP && down && !refused))) + stirred = now_ms(); + keysdown[action] = down; } @@ -194,7 +205,7 @@ namespace ms { if (type == InventoryType::Id::USE && (itemid / 10000 == 228 || itemid / 10000 == 229)) request(SkillBookPacket(slot, itemid), ExclRequest::USE_WAIT); - else if (type == InventoryType::Id::USE) + else if (type == InventoryType::Id::USE && disease::can_use_item(get_diseases())) request(UseItemPacket(slot, itemid), ExclRequest::USE_WAIT); else if (type == InventoryType::Id::SETUP && itemid / 10000 == 301) sit_on_chair(itemid); @@ -779,12 +790,44 @@ namespace ms diseases.weakened = has_buff(Buffstat::Id::WEAKEN); diseases.confused = has_buff(Buffstat::Id::CONFUSE); + diseases.stop_potion = has_buff(Buffstat::Id::STOP_POTION); + if (has_buff(Buffstat::Id::SEDUCE)) diseases.seduced = get_buff_value(Buffstat::Id::SEDUCE); + if (has_buff(Buffstat::Id::STOP_MOTION)) + diseases.stop_motion = get_buff_value(Buffstat::Id::STOP_MOTION); + return diseases; } + int32_t Player::take_stop_motion() + { + std::optional n = get_diseases().stop_motion; + + if (!n || is_dead()) + return 0; + + int64_t now = now_ms(); + + // A held walking key repeats, as Windows repeats it. + bool confused = has_buff(Buffstat::Id::CONFUSE); + + if (is_held(KeyAction::Id::LEFT) || is_held(KeyAction::Id::RIGHT) || + is_held(confused ? KeyAction::Id::UP : KeyAction::Id::DOWN)) + stirred = now; + + if (now - stirred <= disease::STILL_TIME) + return 0; + + int32_t damage = disease::stop_motion_damage(*n, stats.get_stat(MapleStat::Id::HP)); + + if (damage > 0) + stirred = now; + + return damage; + } + bool Player::has_buff(Buffstat::Id stat) const { return buffs[stat].value > 0; diff --git a/HeavenClient/Character/Player.h b/HeavenClient/Character/Player.h index b053f127d..9abcc2995 100644 --- a/HeavenClient/Character/Player.h +++ b/HeavenClient/Character/Player.h @@ -141,6 +141,9 @@ namespace ms // The diseases on the player. disease::State get_diseases() const; + // What Stop Motion takes now from a player who stood still too + // long, 0 if nothing (0x94b1d7). + int32_t take_stop_motion(); // Change a skill void change_skill(int32_t skill_id, int32_t level, int32_t masterlevel, int64_t expiration); @@ -327,6 +330,9 @@ namespace ms ExclRequest excl; std::map keysdown; + // When the player last moved under Stop Motion, or entered the map + // (+0x311c). + int64_t stirred = 0; move_path::Recorder path; diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index dd5747986..89abb206d 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -988,6 +988,7 @@ namespace ms result.attacker = player.get_oid(); expect_hits(result, 1); + show_counters(result); extract_effects(player, move, result, range, reach, shot_area, shot_end); if (attack.type == Attack::Type::MAGIC) @@ -2579,11 +2580,25 @@ namespace ms mobs.send_attack(result, attack, targets, attack.mobcount); result.attacker = player.get_oid(); expect_hits(result, 1); + show_counters(result); extract_effects(player, skill, result, range, 0, range, position); AttackPacket(result, player.get_position(), position).dispatch(); } + void Combat::show_counters(const AttackResult &result) + { + bool magic = result.type == Attack::Type::MAGIC; + + for (const auto &line : result.damagelines) + { + int32_t damage = mobs.counter(line.first, magic); + + if (damage > 0) + player.show_damage(damage); + } + } + void Combat::clear_grenades() { grenades.clear(); diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index 2cc9c330d..4c9c98b3c 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -321,6 +321,11 @@ namespace ms // part as its target is hit (`staggered`) or all at once. void draw_chain(const Char &user, const SpecialMove &move, const AttackResult &result, bool staggered); + // Each target with a counter hurts the player back; v83 shows its + // own roll over the head and sends nothing (FUN_00953595): the + // server takes the HP. + void show_counters(const AttackResult &result); + // Tell the targets how many hits are coming (sign 1) or cancel them (sign -1), // so a mob the server kills waits for the hits before dying. void expect_hits(const AttackResult &result, int32_t sign); diff --git a/HeavenClient/Gameplay/MapleMap/MapMists.cpp b/HeavenClient/Gameplay/MapleMap/MapMists.cpp index 54e0c846f..d51185f55 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMists.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapMists.cpp @@ -33,18 +33,28 @@ namespace ms { constexpr int16_t FADE = 1000; + nl::node mob_skill_level(int32_t skillid, uint8_t level) + { + return nl::nx::skill["MobSkill.img"][std::to_string(skillid)]["level"][std::to_string(level)]; + } + + // A cash item's mist draws its item's tile (StringPool 0x12eb), a + // hurting mob skill's its level's, and a player's skill's its own. nl::node tile_node(int32_t type, int32_t skillid, uint8_t level) { - if (type == 3) - return nl::nx::skill["MobSkill.img"][string_format::extend_id(skillid, 3)]["level"][std::to_string(level)]["tile"]; + if (type == mist::ITEM) + return nl::nx::item["Cash"]["0528.img"][string_format::extend_id(skillid, 8)]["tile"]; + + if (mist::hurts(skillid)) + return mob_skill_level(skillid, level)["tile"]; return nl::nx::skill[string_format::extend_id(skillid / 10000, 3) + ".img"]["skill"] [string_format::extend_id(skillid, 7)]["tile"]; } } - void MapMists::spawn(int32_t oid, int32_t type, int32_t skillid, uint8_t level, int32_t left, int32_t top, - int32_t right, int32_t bottom) + void MapMists::spawn(int32_t oid, int32_t type, int32_t owner, int32_t skillid, uint8_t level, int16_t delay, + int32_t left, int32_t top, int32_t right, int32_t bottom, int32_t element) { nl::node src = tile_node(type, skillid, level); @@ -71,7 +81,11 @@ namespace ms std::stable_sort(layout.begin(), layout.end(), [](const MistCloud &a, const MistCloud &b) { return a.z < b.z; }); + int32_t damage = mist::hurts(skillid) ? static_cast(mob_skill_level(skillid, level)["x"]) : 0; + Mist mist; + mist.area = { type, owner, skillid, level, left, top, right, bottom, element, damage, delay <= 0 }; + mist.wait = delay * 100; for (const MistCloud &cloud : layout) mist.clouds.push_back({ tiles[cloud.tile], Point(cloud.x, cloud.y), cloud.flip }); @@ -96,6 +110,12 @@ namespace ms for (Cloud &cloud : mist.clouds) cloud.animation.update(); + if (!mist.area.started) + { + mist.wait -= Constants::TIMESTEP; + mist.area.started = mist.wait <= 0; + } + if (mist.fading >= 0) { mist.fading -= Constants::TIMESTEP; @@ -129,4 +149,26 @@ namespace ms { mists.clear(); } + + bool MapMists::hurt_at(Point position, mist::Hurt &hurt) const + { + return mist::hurt_at(areas(), position.x(), position.y(), hurt); + } + + bool MapMists::shelters(Point position, const std::function &ally) const + { + return mist::shelters(areas(), position.x(), position.y(), ally); + } + + // A fading mist is gone to the client, which removes its area at once. + std::vector MapMists::areas() const + { + std::vector areas; + + for (const auto &iter : mists) + if (iter.second.fading < 0) + areas.push_back(iter.second.area); + + return areas; + } } diff --git a/HeavenClient/Gameplay/MapleMap/MapMists.h b/HeavenClient/Gameplay/MapleMap/MapMists.h index 8acecffff..94b1008bd 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMists.h +++ b/HeavenClient/Gameplay/MapleMap/MapMists.h @@ -21,6 +21,7 @@ #include "../../Graphics/Animation.h" +#include #include namespace ms @@ -31,9 +32,10 @@ namespace ms { public: // Cover an area with a skill's clouds (AFFECTED_AREA_CREATED, - // FUN_00431a63). A mob's mist (type 3) uses its MobSkill.img level. - void spawn(int32_t oid, int32_t type, int32_t skillid, uint8_t level, int32_t left, int32_t top, - int32_t right, int32_t bottom); + // FUN_00431a63), starting after `delay` 100 ms. A mob skill's mist + // uses its MobSkill.img level, a cash item's (type 3) its item. + void spawn(int32_t oid, int32_t type, int32_t owner, int32_t skillid, uint8_t level, int16_t delay, + int32_t left, int32_t top, int32_t right, int32_t bottom, int32_t element); // Fade a mist's clouds out over a second (FUN_0043234d). void remove(int32_t oid); @@ -44,6 +46,12 @@ namespace ms void clear(); + // Whether a mob's mist hurts the player at `position`, and how. + bool hurt_at(Point position, mist::Hurt &hurt) const; + + // Whether an ally's smoke screen shelters the player at `position`. + bool shelters(Point position, const std::function &ally) const; + private: struct Cloud { @@ -54,11 +62,16 @@ namespace ms struct Mist { + mist::Area area; + // The ms left before it starts. + int32_t wait; std::vector clouds; // The ms left of its fade, once removed. int16_t fading = -1; }; + std::vector areas() const; + std::map mists; }; } diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp index 1da060961..654f58328 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp @@ -113,6 +113,14 @@ namespace ms mob->show_damage(damage); } + int32_t MapMobs::counter(int32_t oid, bool magic) const + { + if (Optional mob = mobs.get(oid)) + return mob->counter(magic); + + return 0; + } + void MapMobs::send_mobhp(int32_t oid, int8_t percent, uint16_t playerlevel) { if (Optional mob = mobs.get(oid)) diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.h b/HeavenClient/Gameplay/MapleMap/MapMobs.h index 244ade70b..8b947d5ad 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.h +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.h @@ -63,6 +63,10 @@ namespace ms // Show a number on a mob: damage, a heal if negative, or a miss. void show_damage(int32_t oid, int32_t damage); + // What a mob's counter hurts the player with for one of their + // attacks, or 0. + int32_t counter(int32_t oid, bool magic) const; + // Put temporary statuses on a mob, showing their effects. void set_statuses(int32_t oid, const std::vector &statuses); diff --git a/HeavenClient/Gameplay/MapleMap/MistLayout.h b/HeavenClient/Gameplay/MapleMap/MistLayout.h index 8bb2553ef..1757fd0ae 100644 --- a/HeavenClient/Gameplay/MapleMap/MistLayout.h +++ b/HeavenClient/Gameplay/MapleMap/MistLayout.h @@ -78,4 +78,92 @@ namespace ms return clouds; } + + // What a mist does to the player standing in it. + namespace mist + { + // Its type, AFFECTED_AREA_CREATED's second int: a mob's mist hurts, + // a smoke screen shelters, and type 3 is a cash item's. + enum Type : int32_t + { + MOB = 0, + SMOKE = 2, + ITEM = 3 + }; + + // The mob skills whose mist hurts by their level's `x`: 130 and + // Poison Mist (FUN_00431a63). + constexpr bool hurts(int32_t skillid) + { + return skillid == 130 || skillid == 131; + } + + struct Area + { + int32_t type; + int32_t owner; + int32_t skillid; + int32_t level; + int32_t left; + int32_t top; + int32_t right; + int32_t bottom; + // The element its damage is resisted as, the packet's last int. + int32_t element; + // What it does a second, its level's `x`, if it hurts. + int32_t damage; + // Whether its delay is over. + bool started; + + // The client asks PtInRect, so the right and bottom edges are out. + constexpr bool contains(int32_t x, int32_t y) const + { + return left <= x && x < right && top <= y && y < bottom; + } + }; + + // The hurt a mist does, and the code TAKE_DAMAGE names it by: its + // skill's low byte, then its level. + struct Hurt + { + int32_t damage; + uint8_t element; + uint16_t code; + }; + + // The first started mob mist the player at (x, y) is in hurts them + // its damage (FUN_00431783). Nothing comes of one that does none. + template + bool hurt_at(const Areas &areas, int32_t x, int32_t y, Hurt &hurt) + { + for (const Area &area : areas) + { + if (area.type != MOB || !area.started || !area.contains(x, y)) + continue; + + hurt = { area.damage, static_cast(area.element), + static_cast((area.skillid & 0xFF) << 8 | (area.level & 0xFF)) }; + + return area.damage > 0; + } + + return false; + } + + // A started smoke screen of the player's own or a party member's + // keeps mobs off them (FUN_0043193a); `ally(owner)` says whose. + template + bool shelters(const Areas &areas, int32_t x, int32_t y, Ally ally) + { + for (const Area &area : areas) + if (area.type == SMOKE && area.started && area.contains(x, y) && ally(area.owner)) + return true; + + return false; + } + + // How long apart a mist hurts the player, in ms: more than this + // since the last time (0x94b7a2). + constexpr int32_t HURT_INTERVAL = 1000; + } } diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index f119bb4e8..4e359a7fe 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -2225,6 +2225,17 @@ namespace ms return found != statuses.end() ? static_cast(found->second.value) : 0; } + int32_t Mob::counter(bool magic) const + { + auto found = statuses.find(magic ? MobStatParser::MCOUNTER : MobStatParser::PCOUNTER); + + if (found == statuses.end()) + return 0; + + return MobStatParser::counter(found->second, randomizer.next_int(UINT32_MAX), + randomizer.next_int(UINT32_MAX)); + } + void Mob::apply_death() { show_stance(Stance::DIE, flip); diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 481b745b5..1a5736d39 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -227,6 +227,10 @@ namespace ms // miss (FUN_006691d3). void show_damage(int32_t damage); + // What its counter hurts the player with for one of their attacks, + // a spell's (MCounter) or the rest (PCounter), or 0 (FUN_007937e3). + int32_t counter(bool magic) const; + // Return the body's bounds on the map, turned the way the mob faces. Rectangle get_body() const; diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index 8d60cf7a7..8db278b47 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -28,6 +28,7 @@ #include "../Util/Misc.h" #include "../Util/NxFiles.h" +#include "../IO/UITypes/UIChatBar.h" #include "../IO/UITypes/UINotice.h" #include "../IO/UITypes/UINpcTalk.h" #include "../IO/UITypes/UIStatusBar.h" @@ -55,6 +56,7 @@ namespace ms drops.init(); jump_effect = nl::nx::effect["BasicEff.img"]["VerticalJump"]; + fear_sight = nl::nx::effect["MapEff.img"]["Viewrange"]["3"]; } void Stage::load(int32_t mapid, int8_t portalid, std::optional> chase) @@ -182,9 +184,34 @@ namespace ms ground_effects.drawabove(viewpos, alpha); portals.draw(viewpos, alpha); backgrounds.drawforegrounds(viewx, viewy, alpha); + draw_fear(viewx, viewy, alpha); effect.draw(); } + void Stage::draw_fear(double viewx, double viewy, float alpha) const + { + if (!player.has_buff(Buffstat::Id::FEAR)) + return; + + // v83 copies the sight onto a black layer with its top left this + // far from the player's feet, ignoring its origin. + Point sight = player.get_phobj().get_absolute(viewx, viewy, alpha) + Point(-80, -120); + Point size = fear_sight.get_dimensions(); + int16_t width = Constants::Constants::get().get_viewwidth(); + int16_t height = Constants::Constants::get().get_viewheight(); + int16_t left = sight.x(); + int16_t top = sight.y(); + int16_t right = left + size.x(); + int16_t bottom = top + size.y(); + + GraphicsGL &gl = GraphicsGL::get(); + gl.drawrectangle(0, 0, width, top, 0.0f, 0.0f, 0.0f, 1.0f); + gl.drawrectangle(0, bottom, width, height - bottom, 0.0f, 0.0f, 0.0f, 1.0f); + gl.drawrectangle(0, top, left, size.y(), 0.0f, 0.0f, 0.0f, 1.0f); + gl.drawrectangle(right, top, width - right, size.y(), 0.0f, 0.0f, 0.0f, 1.0f); + fear_sight.draw(DrawArgument(sight + fear_sight.get_origin(), Color::cutout(1.0f))); + } + void Stage::update() { if (state != State::ACTIVE) @@ -229,11 +256,20 @@ namespace ms check_touch_portals(drawn); camera.update(player.get_position()); + // The player's or a party member's smoke screen keeps mobs' attacks, + // touch and mists off them (FUN_0043193a). + bool sheltered = mists.shelters(player.get_position(), [&](int32_t owner) { + return owner == player.get_oid() || player.get_party().find(owner); + }); + + if (since_mist_hurt <= mist::HURT_INTERVAL) + since_mist_hurt += Constants::TIMESTEP; + // Mob attacks land on the player where the mob's update found them, // Dark Sight or not; the first takes the player's invincibility. for (Mob::Landed &landed : mobs.take_attacks()) { - if (player.is_dead() || player.Char::is_invincible()) + if (player.is_dead() || player.Char::is_invincible() || sheltered) break; MobAttackResult result = player.damage(landed.attack); @@ -244,18 +280,45 @@ namespace ms player.show_attack_effect(landed.hit, 1); } - if (player.is_invincible()) + if (player.is_dead() || player.Char::is_invincible() || sheltered) return; - if (int32_t oid_id = mobs.find_colliding(player.get_phobj())) + int32_t touching = player.is_invincible() ? 0 : mobs.find_colliding(player.get_phobj()); + + if (touching) { - if (MobAttack attack = mobs.create_attack(oid_id)) + if (MobAttack attack = mobs.create_attack(touching)) { MobAttackResult result = player.damage(attack); TakeDamagePacket(result).dispatch(); combat.interrupt_prepare(result.damage); } } + else if (since_mist_hurt > mist::HURT_INTERVAL) + { + // Not touching a mob, the player in a mob's mist hurts + // themselves its x, without a knockback or invincibility + // (FUN_00431783 at 0x94b7ba). + mist::Hurt hurt; + + if (mists.hurt_at(player.get_position(), hurt)) + { + player.show_damage(hurt.damage); + TakeDamagePacket(hurt.element, hurt.damage, hurt.code).dispatch(); + since_mist_hurt = 0; + } + } + + // Stop Motion hurts a player who stood still two seconds. + if (int32_t damage = player.take_stop_motion()) + { + player.show_damage(damage); + + if (auto chatbar = UI::get().get_element()) + chatbar->send_chatline(disease::STOP_MOTION_HURT, UIChatBar::LineType::GREY); + + TakeDamagePacket(4, damage, 0, 3).dispatch(); + } } void Stage::show_character_effect(int32_t cid, CharEffect::Id effect) diff --git a/HeavenClient/Gameplay/Stage.h b/HeavenClient/Gameplay/Stage.h index b1275f89c..efbc9475d 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -191,6 +191,9 @@ namespace ms void check_drops(); + // Fear's darkness, all but the player's narrowed sight (FUN_0053868d). + void draw_fear(double viewx, double viewy, float alpha) const; + enum State { INACTIVE, @@ -228,12 +231,16 @@ namespace ms MapMobs mobs; MapDrops drops; MapMists mists; + // The ms since a mob's mist last hurt the player. + int32_t since_mist_hurt = mist::HURT_INTERVAL + 1; MapDoors doors; MapSummons summons; MapEffect effect; // Effects left where they started, such as a jump pad's burst. EffectLayer ground_effects; Animation jump_effect; + // What Fear leaves the player to see (Effect/MapEff.img/Viewrange/3). + Texture fear_sight; Combat combat; EmoteCooldown emote_cooldown; diff --git a/HeavenClient/Graphics/Color.h b/HeavenClient/Graphics/Color.h index 4f132df34..91c9fd8af 100644 --- a/HeavenClient/Graphics/Color.h +++ b/HeavenClient/Graphics/Color.h @@ -112,6 +112,7 @@ namespace ms WEAKMOB, STRONGMOB, PICTONBLUE, + MAYABLUE, NUM_COLORS }; @@ -173,7 +174,8 @@ namespace ms {0.63f, 0.01f, 0.00f}, // HP shade (#A00300, the bottom row of v83's mob and party HP bars) {1.00f, 1.00f, 0.13f}, // Weak mob (#FFFF20, v83 font 3: names of mobs 20+ levels below the player) {0.75f, 0.24f, 0.01f}, // Strong mob (#BE3C03, v83 font 42: names of mobs 20+ levels above the player) - {0.39f, 0.71f, 0.94f} // Picton blue (#64B4F0, v83 font 5: quest messages in the status lines) + {0.39f, 0.71f, 0.94f}, // Picton blue (#64B4F0, v83 font 5: quest messages in the status lines) + {0.38f, 0.81f, 1.00f} // Maya blue (#60CEFF, v83 chat font 10: server notices) }; // Create a color by an array of real numbers [0.0f, 1.0f] @@ -220,6 +222,14 @@ namespace ms return Color(-1.0f, 1.0f, 1.0f, opacity); } + // Draw black where a texture is clear and nothing where it is opaque, + // as v83 cuts Fear's sight from its darkness. The shader reads a + // negative green as this. + static constexpr Color cutout(float opacity) + { + return Color(1.0f, -1.0f, 1.0f, opacity); + } + // Check whether the color is completely invisible constexpr bool invisible() const { diff --git a/HeavenClient/Graphics/GraphicsGL.cpp b/HeavenClient/Graphics/GraphicsGL.cpp index b8ce70a0d..6e76df3ee 100644 --- a/HeavenClient/Graphics/GraphicsGL.cpp +++ b/HeavenClient/Graphics/GraphicsGL.cpp @@ -106,6 +106,10 @@ namespace ms " {" " gl_FragColor = vec4(1, 1, 1, texture2D(texture, texpos / atlassize).r) * colormod;" " }" + " else if (colormod.g < 0.0)" + " {" + " gl_FragColor = vec4(0, 0, 0, colormod.a * (1.0 - texture2D(texture, texpos / atlassize).a));" + " }" " else if (colormod.r < 0.0)" " {" " vec4 texel = texture2D(texture, texpos / atlassize);" diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index c9d21c0cc..a4a89a39e 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -601,7 +601,7 @@ namespace ms color = Color::Name::DARKRED; break; case LineType::BLUE: - color = Color::Name::MEDIUMBLUE; + color = Color::Name::MAYABLUE; break; case LineType::YELLOW: color = Color::Name::YELLOW; diff --git a/HeavenClient/IO/UITypes/UIChatBar.h b/HeavenClient/IO/UITypes/UIChatBar.h index 2f3556096..8b5660801 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.h +++ b/HeavenClient/IO/UITypes/UIChatBar.h @@ -53,6 +53,7 @@ namespace ms UNK0, WHITE, RED, + // Server notices BLUE, YELLOW, GREY, diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index e2db2d600..e9e0f5729 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -332,7 +332,9 @@ namespace ms break; case InventoryType::Id::USE: - Stage::get().get_player().request(UseItemPacket(slot, item_id), ExclRequest::USE_WAIT); + if (disease::can_use_item(Stage::get().get_player().get_diseases())) + Stage::get().get_player().request(UseItemPacket(slot, item_id), ExclRequest::USE_WAIT); + break; case InventoryType::Id::SETUP: Stage::get().get_player().use_item(item_id); diff --git a/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp b/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp index 055029ca1..49deda6d8 100644 --- a/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/BuffParser.cpp @@ -16,6 +16,8 @@ // along with this program. If not, see . // #include "BuffParser.h" +#include "../Character/Diseases.h" + namespace ms { namespace @@ -40,8 +42,7 @@ namespace ms BYTE, SHORT, INT, - SHORT_INT, - INT_BYTE_BYTE + SHORT_INT }; // What another character's client reads for each stat it shows, @@ -51,7 +52,7 @@ namespace ms { 31, INT }, { 18, SHORT_INT }, { 26, NOTHING }, { 10, NOTHING }, { 16, NOTHING }, { 33, SHORT }, { 49, SHORT }, { 39, INT }, { 40, INT }, { 46, INT }, { 50, INT }, { 62, INT }, { 51, INT }, { 53, INT }, { 54, INT }, { 55, INT }, { 56, INT }, { 59, NOTHING }, { 60, NOTHING }, - { 66, NOTHING }, { 73, INT }, { 75, INT }, { 76, INT }, { 77, INT_BYTE_BYTE } + { 66, NOTHING }, { 73, INT }, { 75, INT }, { 76, INT }, { 77, INT } }; // GIVE_BUFF reads in ascending order, except for these. @@ -100,6 +101,10 @@ namespace ms if (flags.has(bit) && Buffstat::by_bit(bit) != Buffstat::NONE) ids.push_back(Buffstat::by_bit(bit)); + // Undead ends with Puppet's bit. + if (flags.has(46)) + ids.push_back(Buffstat::UNDEAD); + return ids; } @@ -133,13 +138,9 @@ namespace ms recv.skip_short(); value = recv.read_int(); break; - case INT_BYTE_BYTE: - value = recv.read_int(); - recv.skip(2); - break; } - Buffstat::Id stat = Buffstat::by_bit(bit); + Buffstat::Id stat = disease::given(Buffstat::by_bit(bit), value); if (stat != Buffstat::NONE) shown.emplace_back(stat, value); diff --git a/HeavenClient/Net/Handlers/Helpers/MobStatParser.cpp b/HeavenClient/Net/Handlers/Helpers/MobStatParser.cpp index 4cd6ed168..f9ff32f0b 100644 --- a/HeavenClient/Net/Handlers/Helpers/MobStatParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/MobStatParser.cpp @@ -17,6 +17,8 @@ #pragma once #include "MobStatParser.h" +#include + namespace ms { namespace @@ -51,14 +53,36 @@ namespace ms recv.skip(12 * count); } - if (flags.has(PCOUNTER)) - recv.skip(4); + auto find = [&statuses](uint8_t stat) + { + return std::find_if(statuses.begin(), statuses.end(), + [stat](const Status &status) { return status.stat == stat; }); + }; + + for (uint8_t stat : { PCOUNTER, MCOUNTER }) + { + if (!flags.has(stat)) + continue; - if (flags.has(MCOUNTER)) - recv.skip(4); + int32_t spread = recv.read_int(); + auto found = find(stat); + + if (found != statuses.end()) + found->spread = spread; + } if (flags.has(PCOUNTER) || flags.has(MCOUNTER)) - recv.skip(4); + { + int32_t chance = recv.read_int(); + + for (uint8_t stat : { PCOUNTER, MCOUNTER }) + { + auto found = find(stat); + + if (found != statuses.end()) + found->chance = chance; + } + } if (flags.has(DISABLE)) { diff --git a/HeavenClient/Net/Handlers/Helpers/MobStatParser.h b/HeavenClient/Net/Handlers/Helpers/MobStatParser.h index 41ab9f078..a52655e6e 100644 --- a/HeavenClient/Net/Handlers/Helpers/MobStatParser.h +++ b/HeavenClient/Net/Handlers/Helpers/MobStatParser.h @@ -68,16 +68,38 @@ namespace ms int32_t reason; // How long it lasts, in 500 ms. int16_t duration; + // A counter's spread, and the chance in percent shared by both + // counters that one hurts (0 always). + int32_t spread = 0; + int32_t chance = 0; }; // The statuses whose flags are set (FUN_0078b0b1), as MOB_STAT_SET - // and the spawn packets carry them. The burns and counters are read - // past; Disable is kept with its first byte as its value. + // and the spawn packets carry them. The burns are read past; the + // counters keep their spreads and chance; Disable is kept with its + // first byte as its value. std::vector parse(InPacket& recv, const BuffParser::Flags& flags); // Read past the burns MOB_STAT_RESET ends (FUN_0078c70a). void skip_reset(InPacket& recv, const BuffParser::Flags& flags); + // What a counter hurts an attacker with for one attack, or 0 + // (FUN_007937e3): with a chance, only when the first roll modulo + // 100 is under it; its value, give or take half its spread by the + // second roll. + inline int32_t counter(const Status& status, uint32_t chance_roll, uint32_t spread_roll) + { + if (status.chance != 0 && chance_roll % 100 >= static_cast(status.chance)) + return 0; + + if (status.spread == 0) + return status.value; + + int32_t spread = static_cast(spread_roll % static_cast(status.spread)); + + return status.value + spread - status.spread / 2; + } + // Whether a byte follows for the mob's next action: Stun, Freeze, // Doom or bit 32 changed (FUN_0068ad7f). bool stops(const BuffParser::Flags& flags); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 57fed5f94..254659f36 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -577,19 +577,20 @@ namespace ms void SpawnMistHandler::handle(InPacket& recv) const { // v83 FUN_00431a63. The delay (in 100 ms) only starts the mist's own - // clock; the clouds show at once. The last int is unused here. + // clock; the clouds show at once. The last int is its element. int32_t oid = recv.read_int(); int32_t type = recv.read_int(); - recv.skip_int(); + int32_t owner = recv.read_int(); int32_t skillid = recv.read_int(); uint8_t level = recv.read_byte(); - recv.skip_short(); + int16_t delay = recv.read_short(); int32_t left = recv.read_int(); int32_t top = recv.read_int(); int32_t right = recv.read_int(); int32_t bottom = recv.read_int(); + int32_t element = recv.read_int(); - Stage::get().get_mists().spawn(oid, type, skillid, level, left, top, right, bottom); + Stage::get().get_mists().spawn(oid, type, owner, skillid, level, delay, left, top, right, bottom, element); } void RemoveMistHandler::handle(InPacket& recv) const diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index c135413e6..1bbbb2412 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -267,26 +267,47 @@ namespace ms void ServerMessageHandler::handle(InPacket& recv) const { + // v83 reads a flag first only for the scrolling notice, which has no + // message when it is off (FUN_00a22785). int8_t type = recv.read_byte(); - bool servermessage = recv.inspect_bool(); - if (servermessage) - recv.skip(1); + if (type == 4 && !recv.read_bool()) + return; std::string message = recv.read_string(); + auto chatbar = UI::get().get_element(); - if (type == 3) - { - recv.read_byte(); // channel - recv.read_bool(); // megaphone - } - else if (type == 4) + switch (type) { - UI::get().set_scrollnotice(message); - } - else if (type == 7) - { - recv.read_int(); // npcid + case 0: + // StringPool 0x37a + if (chatbar) + chatbar->send_chatline("[Notice] " + message, UIChatBar::LineType::BLUE); + break; + case 1: + UI::get().emplace(message, [](bool) {}); + break; + case 3: + recv.read_byte(); // channel + recv.read_bool(); // megaphone + break; + case 4: + UI::get().set_scrollnotice(message); + break; + case 5: + if (chatbar) + chatbar->send_chatline(message, UIChatBar::LineType::SKILL); + break; + case 6: + // An item id, linking the item to the line when not 0 + recv.read_int(); + + if (chatbar) + chatbar->send_chatline(message, UIChatBar::LineType::BLUE); + break; + case 7: + recv.read_int(); // npcid + break; } } diff --git a/HeavenClient/Net/Handlers/PlayerHandlers.cpp b/HeavenClient/Net/Handlers/PlayerHandlers.cpp index dd053c8d8..662eba067 100644 --- a/HeavenClient/Net/Handlers/PlayerHandlers.cpp +++ b/HeavenClient/Net/Handlers/PlayerHandlers.cpp @@ -212,6 +212,7 @@ namespace ms if (bs == Buffstat::NONE) return; + bs = disease::given(bs, skillid); Stage::get().get_player().give_buff({ bs, value, skillid, duration }); // A disease has no icon, only a notice of what it does @@ -238,6 +239,10 @@ namespace ms Stage::get().get_player().cancel_buff(bs); + // Undead ends with Puppet's bit. + if (bs == Buffstat::PUPPET) + Stage::get().get_player().cancel_buff(Buffstat::UNDEAD); + if (auto bufflist = UI::get().get_element()) bufflist->cancel_buff(bs); } diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 3a224a16b..9120dbb70 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -123,7 +123,8 @@ namespace ms public: enum From : int8_t { - TOUCH = -1 + TOUCH = -1, + MIST = -4 }; TakeDamagePacket(int8_t from, uint8_t element, int32_t damage, int32_t mobid, int32_t oid, uint8_t direction) : OutPacket(OutPacket::Opcode::TAKE_DAMAGE) @@ -139,6 +140,19 @@ namespace ms // From a mob's touch or attack. TakeDamagePacket(const MobAttackResult& result) : TakeDamagePacket(result.index, result.element, result.damage, result.mobid, result.oid, result.direction) {} + + // From a mob's mist, named by its skill and level (`code`) in place + // of a mob (FUN_00431783), whose last byte is 2; or from Stop + // Motion, element 4 with no code and a last byte of 3 (0x94b1d7). + TakeDamagePacket(uint8_t element, int32_t damage, uint16_t code, uint8_t last = 2) : OutPacket(OutPacket::Opcode::TAKE_DAMAGE) + { + write_time(); + write_byte(MIST); + write_byte(element); + write_int(damage); + write_short(static_cast(code)); + write_byte(last); + } }; // Packet which notifies the server of a skill usage. diff --git a/HeavenClient/tests/buff_parser.cpp b/HeavenClient/tests/buff_parser.cpp index b6594c3eb..7ea116986 100644 --- a/HeavenClient/tests/buff_parser.cpp +++ b/HeavenClient/tests/buff_parser.cpp @@ -89,6 +89,21 @@ int main() shown[3].first == Buffstat::SHADOWPARTNER, "combo, charge, poison (by its reason) and shadow partner"); require(buff.read_short() == 0 && !buff.available(), "the delay follows"); + // Undead shares Puppet's bit 46, told apart by its mob skill; Stop + // Potion, Stop Motion and Fear each read an int. + BuffParser::Flags cursed{ 0, 0 }; + for (uint8_t bit : { 46, 75, 76, 77 }) + cursed.set(bit); + Writer d; + d.flags(cursed).put(133 | 1 << 16).put(134 | 1 << 16).put(135 | 1 << 16) + .put(136 | 1 << 16).zeros(2).put(0); + InPacket curse(d.bytes.data(), d.bytes.size()); + shown = BuffParser::parse_shown(curse, BuffParser::read_flags(curse)); + require(shown.size() == 4 && shown[0].first == Buffstat::UNDEAD && shown[1].first == Buffstat::STOP_POTION && + shown[2].first == Buffstat::STOP_MOTION && shown[3].first == Buffstat::FEAR, + "Undead, Stop Potion, Stop Motion and Fear"); + require(curse.read_short() == 0 && !curse.available(), "and the delay follows"); + std::cout << "Buff parser passed\n"; return 0; } diff --git a/HeavenClient/tests/diseases.cpp b/HeavenClient/tests/diseases.cpp index 9d71b6f88..f0a4d9ca1 100644 --- a/HeavenClient/tests/diseases.cpp +++ b/HeavenClient/tests/diseases.cpp @@ -49,9 +49,25 @@ int main() require(can_attack(sealed), "a seal leaves attacks"); require(!can_use_skill(2001002, sealed) && can_use_skill(DISPEL, sealed), "and Dispel, but no other skill"); + State potionless; + potionless.stop_potion = true; + require(!can_use_item(potionless) && !can_use_item(attracted) && can_use_item(State{}), + "Stop Potion and Seduce refuse items"); + + require(stop_motion_damage(30, 500) == 30 && stop_motion_damage(30, 31) == 30, "Stop Motion takes its n"); + require(stop_motion_damage(30, 30) == 29 && stop_motion_damage(30, 1) == 0, "but never the last HP"); + + require(given(ms::Buffstat::PUPPET, 133 | 1 << 16) == ms::Buffstat::UNDEAD, "bit 46 from mob skill 133 is Undead"); + require(given(ms::Buffstat::PUPPET, 3111002) == ms::Buffstat::PUPPET && given(ms::Buffstat::POISON, 133 | 1 << 16) == ms::Buffstat::POISON, + "else it is what it was"); + require(walk_speed(140, 80) == 80 && walk_speed(140, std::nullopt) == 140, "Slow sets the speed"); require(notice(123) == "You've been stunned, making you unable to move.", "a stun says so"); + require(notice(133) == "You will become an Undead and the effects of your Recovery will diminish by 50%.", + "Undead says so"); + require(notice(136) == "The fear of darkness has overtaken you! Your range of vision has been narrowed.", + "and Fear"); require(notice(126).empty() && notice(132).empty(), "Slow and Confuse say nothing"); std::cout << "Diseases passed\n"; diff --git a/HeavenClient/tests/mist_layout.cpp b/HeavenClient/tests/mist_layout.cpp index 9471406b6..8ef853b84 100644 --- a/HeavenClient/tests/mist_layout.cpp +++ b/HeavenClient/tests/mist_layout.cpp @@ -40,6 +40,36 @@ int main() require(clouds.front().x == -200 && clouds.front().y == -75, "the first cloud at the left edge's top"); require(clouds[1].y == 1, "-75 + 76 is the next"); require(layout_mist(0, 0, 0, 100, { 50 }, lowest).empty(), "an empty area has no clouds"); + + // A Commander Skeleton's Poison Mist (131 level 5) hurts its x, as + // poison, once started; the code is the skill's byte, then the level. + mist::Area poison{ mist::MOB, 2000001, 131, 5, -135, -80, 135, 40, 4, 200, true }; + std::vector areas{ poison }; + mist::Hurt hurt{}; + require(mist::hurt_at(areas, 0, 0, hurt), "standing in it hurts"); + require(hurt.damage == 200 && hurt.element == 4 && hurt.code == (131 << 8 | 5), "its x, element and code"); + require(mist::hurt_at(areas, -135, -80, hurt), "its top left is in"); + require(!mist::hurt_at(areas, 135, 0, hurt) && !mist::hurt_at(areas, 0, 40, hurt), + "its right and bottom edges are out (PtInRect)"); + areas[0].started = false; + require(!mist::hurt_at(areas, 0, 0, hurt), "nor before its delay is over"); + areas[0] = poison; + areas[0].type = 1; + require(!mist::hurt_at(areas, 0, 0, hurt), "only a mob's mist hurts"); + areas[0] = poison; + areas[0].damage = 0; + require(!mist::hurt_at(areas, 0, 0, hurt), "one that does nothing doesn't"); + require(mist::hurts(130) && mist::hurts(131) && !mist::hurts(2111003), "the hurting mob skills"); + + // A smoke screen shelters its owner and their party, not others. + mist::Area smoke{ mist::SMOKE, 7, 4221006, 20, -100, -100, 100, 100, 0, 0, true }; + std::vector smokes{ smoke }; + auto ally = [](int32_t owner) { return owner == 7; }; + auto stranger = [](int32_t) { return false; }; + require(mist::shelters(smokes, 0, 0, ally), "an ally's smoke shelters"); + require(!mist::shelters(smokes, 0, 0, stranger), "a stranger's doesn't"); + require(!mist::shelters(smokes, 200, 0, ally), "nor outside it"); + require(!mist::shelters(areas, 0, 0, ally), "a poison mist doesn't"); } catch (const std::exception& e) { diff --git a/HeavenClient/tests/mob_stat_parser.cpp b/HeavenClient/tests/mob_stat_parser.cpp index c2df07bfd..cf30a712a 100644 --- a/HeavenClient/tests/mob_stat_parser.cpp +++ b/HeavenClient/tests/mob_stat_parser.cpp @@ -60,7 +60,18 @@ int main() require(statuses[2].stat == MobStatParser::DOOM && statuses[2].reason == 2311005, "Then Doom"); require(statuses[3].stat == MobStatParser::WEB && statuses[3].duration == 16, "Then Web"); require(statuses[4].stat == MobStatParser::PCOUNTER, "And the counter"); - require(recv.read_short() == -1, "The counter's two ints are read past"); + require(statuses[4].spread == 7 && statuses[4].chance == 8, "The counter keeps its spread and chance"); + require(recv.read_short() == -1, "The counter's two ints are read"); + + // Its value give or take half its spread, when the chance comes up. + MobStatParser::Status counter = { MobStatParser::PCOUNTER, 220, 145 | 5 << 16, 20, 98, 0 }; + require(MobStatParser::counter(counter, 99, 0) == 220 - 49, "Half the spread off"); + require(MobStatParser::counter(counter, 99, 97) == 220 + 48, "Up to half the spread on"); + counter.spread = 0; + require(MobStatParser::counter(counter, 99, 97) == 220, "No spread, just the value"); + counter.chance = 30; + require(MobStatParser::counter(counter, 129, 0) == 220, "Under the chance it hurts"); + require(MobStatParser::counter(counter, 130, 0) == 0, "At it or over, not"); require(MobStatParser::stops(flags), "Doom stops the mob"); diff --git a/game-data/src/mobs.rs b/game-data/src/mobs.rs index 9d34454d1..bdbec1bca 100644 --- a/game-data/src/mobs.rs +++ b/game-data/src/mobs.rs @@ -37,6 +37,17 @@ pub struct MobInfo { pub attacks: Vec, /// The mob skills it may cast (`skill/`), in order. pub skills: Vec, + /// Where its Banish sends players (`ban`). + pub banish: Option, +} + +/// Where a mob's Banish sends players (`ban/banMap/0`), and what it tells +/// them (`banMsg`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Banish { + pub map_id: i32, + pub portal: String, + pub message: String, } /// One of a mob's skills (`skill/`). @@ -135,6 +146,30 @@ impl MobData { element: nx.string_child(info, "elemAttr")?.unwrap_or_default(), attacks: self.attacks(image)?, skills: self.skills(info)?, + banish: self.banish(info)?, + })) + } + + fn banish(&self, info: u32) -> Result, GameDataError> { + let nx = &self.nx; + let Some(ban) = nx.child_by_name(info, "ban")? else { + return Ok(None); + }; + let Some(to) = nx + .child_by_name(ban, "banMap")? + .map(|maps| nx.child_by_name(maps, "0")) + .transpose()? + .flatten() + else { + return Ok(None); + }; + let Some(map_id) = nx.int_child(to, "field")? else { + return Ok(None); + }; + Ok(Some(Banish { + map_id, + portal: nx.string_child(to, "portal")?.unwrap_or_default(), + message: nx.string_child(ban, "banMsg")?.unwrap_or_default(), })) } @@ -204,6 +239,19 @@ mod tests { assert!(data.info(9999998).unwrap().is_none()); } + #[test] + fn reads_where_banish_sends_players() { + let Some(path) = crate::test_assets::path("Mob.nx") else { + return; + }; + let data = MobData::load(path).unwrap(); + // Shade: to Kerning City's subway ticketing booth. + let banish = data.info(5090000).unwrap().unwrap().banish.unwrap(); + assert_eq!((banish.map_id, banish.portal.as_str()), (103000100, "sp")); + assert!(banish.message.ends_with("Subway Ticketing Booth.")); + assert_eq!(data.info(100100).unwrap().unwrap().banish, None); + } + #[test] fn reads_elements_and_fixed_damage() { let Some(path) = crate::test_assets::path("Mob.nx") else { diff --git a/game-data/src/skills.rs b/game-data/src/skills.rs index 697e81165..7d2ac04a0 100644 --- a/game-data/src/skills.rs +++ b/game-data/src/skills.rs @@ -94,6 +94,8 @@ pub struct MobSkillLevel { pub prop: i32, /// The most of its summons alive at once, or 0. pub limit: i32, + /// The most players it reaches (`count`), or 0 for no limit. + pub count: i32, /// The area it covers around the mob facing left (`lt`, `rb`), (0, 0) /// for just the mob. pub lt: (i32, i32), @@ -102,6 +104,9 @@ pub struct MobSkillLevel { pub summon_effect: i32, /// The mobs it summons (`0`, `1`, ...). pub summons: Vec, + /// Its element (`elemAttr`), as v83 clients number them for a mist's + /// damage (FUN_0095fa8b): 1 ice, 2 fire, 3 lightning, 4 poison, else 0. + pub element: i32, } pub struct SkillData { @@ -219,10 +224,18 @@ impl SkillData { hp: nx.int_child(node, "hp")?.unwrap_or(100), prop: nx.int_child(node, "prop")?.unwrap_or(100), limit: int("limit")?, + count: int("count")?, lt: nx.vector_child(node, "lt")?.unwrap_or_default(), rb: nx.vector_child(node, "rb")?.unwrap_or_default(), summon_effect: int("summonEffect")?, summons, + element: match nx.string_child(node, "elemAttr")?.as_deref() { + Some("I") => 1, + Some("F") => 2, + Some("L") => 3, + Some("S") => 4, + _ => 0, + }, })) } } @@ -309,5 +322,8 @@ mod tests { assert_eq!(summon.summons, [7130200]); assert_eq!((summon.limit, summon.summon_effect, summon.hp), (5, 2, 75)); assert!(data.mob_skill(100, 99).unwrap().is_none()); + let mist = data.mob_skill(131, 5).unwrap().unwrap(); + assert_eq!((mist.x, mist.duration, mist.element), (200, 15, 4)); + assert_eq!(power_up.element, 0); } } diff --git a/game/src/buff.rs b/game/src/buff.rs index 4a19b9e5b..fa61d1647 100644 --- a/game/src/buff.rs +++ b/game/src/buff.rs @@ -69,17 +69,27 @@ pub enum BuffStat { Puppet = 46, /// The arrow keys are swapped (the client's ReverseInput). Confuse = 51, + /// No potions or other use items. + StopPotion = 75, + /// Standing still for two seconds costs `value` HP, never the last + /// (the client takes it and reports it). + StopMotion = 76, + /// Darkness all around but a ring about the player. + Fear = 77, + /// Items heal half as much HP. It shares Puppet's bit 46 in the client + /// (as Cosmic's ZOMBIFY and PUPPET do); 128 + 46 keeps it apart here. + Undead = 174, } impl BuffStat { /// Its bit in the client's flags. pub fn bit(self) -> u8 { - self as u8 + self as u8 % 128 } /// Stats the rest of the map is told about, in the order the client /// reads them (FUN_00788156); the others only matter to the player. - pub const SHOWN: [BuffStat; 14] = [ + pub const SHOWN: [BuffStat; 18] = [ BuffStat::Speed, BuffStat::Combo, BuffStat::WeaponCharge, @@ -93,10 +103,14 @@ impl BuffStat { BuffStat::DarkSight, BuffStat::SoulArrow, BuffStat::Seduce, + BuffStat::Undead, BuffStat::Confuse, + BuffStat::StopPotion, + BuffStat::StopMotion, + BuffStat::Fear, ]; - pub const DISEASES: [BuffStat; 9] = [ + pub const DISEASES: [BuffStat; 13] = [ BuffStat::Stun, BuffStat::Poison, BuffStat::Seal, @@ -106,6 +120,10 @@ impl BuffStat { BuffStat::Slow, BuffStat::Seduce, BuffStat::Confuse, + BuffStat::Undead, + BuffStat::StopPotion, + BuffStat::StopMotion, + BuffStat::Fear, ]; pub fn disease(self) -> bool { @@ -330,6 +348,20 @@ impl Buffs { ended } + /// End every buff but the diseases, as a mob's Dispel does. Cooldowns + /// keep running. + pub fn dispel(&mut self) -> Vec { + let mut ended = Vec::new(); + self.active.retain(|buff| { + if buff.stat.disease() { + return true; + } + ended.push(buff.stat); + false + }); + ended + } + /// End every buff and disease, e.g. on death. Cooldowns keep running. pub fn clear(&mut self) -> Vec { self.active.drain(..).map(|buff| buff.stat).collect() @@ -370,6 +402,17 @@ mod tests { assert_eq!(buffs.clear(), vec![BuffStat::Recovery, BuffStat::Speed]); } + #[test] + fn dispel_leaves_the_diseases() { + let start = Instant::now(); + let mut buffs = Buffs::default(); + buffs.give(Buff::new(1001, BuffStat::Recovery, 4, start, 30 * SECOND)); + buffs.give(Buff::new(126, BuffStat::Slow, 80, start, 30 * SECOND)); + buffs.give(Buff::new(1002, BuffStat::Speed, 20, start, 30 * SECOND)); + assert_eq!(buffs.dispel(), [BuffStat::Recovery, BuffStat::Speed]); + assert!(buffs.has(BuffStat::Slow)); + } + #[test] fn cancelling_a_skill_ends_only_its_buffs() { let start = Instant::now(); diff --git a/game/src/character.rs b/game/src/character.rs index 441dec3f9..86dd38f7e 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -350,6 +350,16 @@ impl Player { self.stats.hp } + /// Recover `hp` from a potion or a skill, halved while Undead holds, as + /// its notice says (and Cosmic's ZOMBIFY does). Returns the new HP. + pub fn recover_hp(&mut self, hp: i32) -> i16 { + if self.buffs.has(crate::buff::BuffStat::Undead) { + self.add_hp(hp / 2) + } else { + self.add_hp(hp) + } + } + /// Change MP by `delta`, clamped to `0..=max_mp`. Returns the new MP. pub fn add_mp(&mut self, delta: i32) -> i16 { let mp = (i32::from(self.stats.mp) + delta).clamp(0, i32::from(self.stats.max_mp)); diff --git a/game/src/mob_skill.rs b/game/src/mob_skill.rs index 02fc1f88d..b0fc6a3fe 100644 --- a/game/src/mob_skill.rs +++ b/game/src/mob_skill.rs @@ -6,6 +6,8 @@ use std::time::Duration; /// Heal: the mob and those around it regain HP and MP. pub const HEAL: i32 = 114; +/// Poison Mist: the mob leaves a mist that hurts the players in it. +pub const POISON_MIST: i32 = 131; /// Summon: the mob calls up more mobs. pub const SUMMON: i32 = 200; @@ -15,13 +17,21 @@ pub enum MobSkillEffect { Statuses(Vec<(MobStat, i16)>), Heal, Summon, + /// A mist over its area; a v83 client in it loses `x` HP a second. + Mist, + /// Counters on the caster, or on every mob in its area: each attack on + /// one hurts the attacker `x`, give or take half of `y`. + Counters(Vec), /// A disease on every player in its area. Disease(BuffStat), + /// Ends the skills' buffs of every player in its area. + Dispel, + /// Sends players in its area to the mob's `ban` map. + Banish, } -/// What `skill` does with its value `x`, or `None` for the ones not cast -/// yet: the other diseases (127, 129-131, 133-136: dispel, banish, poison -/// mist, ...) and the damage counters (143-145). +/// What `skill` does with its value `x`, or `None` for one no mob has +/// (130). /// /// The v83 client multiplies a mob's attack by its PowerUp and MagicUp as /// percents, and adds Pad and Mad, so Power Up (100, 115%) is PowerUp, not @@ -46,6 +56,17 @@ pub fn effect(skill: i32, x: i32) -> Option { 157 => MobStat::SealSkill, HEAL => return Some(MobSkillEffect::Heal), SUMMON => return Some(MobSkillEffect::Summon), + POISON_MIST => return Some(MobSkillEffect::Mist), + 143 => return Some(MobSkillEffect::Counters(vec![MobStat::PCounter])), + 144 => return Some(MobSkillEffect::Counters(vec![MobStat::MCounter])), + 145 => { + return Some(MobSkillEffect::Counters(vec![ + MobStat::PCounter, + MobStat::MCounter, + ])) + } + 127 => return Some(MobSkillEffect::Dispel), + 129 => return Some(MobSkillEffect::Banish), _ => return disease(skill).map(MobSkillEffect::Disease), }; Some(MobSkillEffect::Statuses(vec![(stat, x)])) @@ -63,6 +84,10 @@ pub fn disease(skill: i32) -> Option { 126 => BuffStat::Slow, 128 => BuffStat::Seduce, 132 => BuffStat::Confuse, + 133 => BuffStat::Undead, + 134 => BuffStat::StopPotion, + 135 => BuffStat::StopMotion, + 136 => BuffStat::Fear, _ => return None, }) } @@ -91,11 +116,18 @@ mod tests { ); assert_eq!(effect(HEAL, 700), Some(MobSkillEffect::Heal)); assert_eq!(effect(SUMMON, 0), Some(MobSkillEffect::Summon)); + assert_eq!(effect(POISON_MIST, 30), Some(MobSkillEffect::Mist)); assert_eq!( effect(123, 1), Some(MobSkillEffect::Disease(BuffStat::Stun)) ); - assert_eq!(effect(127, 1), None); + assert_eq!(effect(127, 0), Some(MobSkillEffect::Dispel)); + assert_eq!(effect(129, 0), Some(MobSkillEffect::Banish)); + assert_eq!( + effect(136, 1), + Some(MobSkillEffect::Disease(BuffStat::Fear)) + ); + assert_eq!(effect(130, 1), None); let slow = inflicted(126, 2, 80, 10).unwrap(); assert_eq!( (slow.stat, slow.value, slow.skill_id, slow.lasts), @@ -103,6 +135,12 @@ mod tests { ); assert_eq!(inflicted(123, 1, 0, 5).unwrap().value, 1); assert_eq!(inflicted(100, 1, 0, 5), None); - assert_eq!(effect(145, 1), None); + assert_eq!( + effect(145, 220), + Some(MobSkillEffect::Counters(vec![ + MobStat::PCounter, + MobStat::MCounter + ])) + ); } } diff --git a/game/src/mob_stat.rs b/game/src/mob_stat.rs index 642a349a1..a5fe8cf1b 100644 --- a/game/src/mob_stat.rs +++ b/game/src/mob_stat.rs @@ -4,8 +4,9 @@ /// A temporary status, by its bit in the v83 client's 128-bit mob flags /// (the initializers from 0x78973c). The names follow later clients; the /// bits are the v83 ones. Each carries a value, the skill that caused it, -/// and how long it lasts. Burned (27), the counters (29, 30), and -/// Disable (31) carry more, and aren't here. +/// and how long it lasts. The counters (29, 30) also carry a spread +/// (`MobStatusView::spread`); Burned (27) and Disable (31) carry more, and +/// aren't here. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum MobStat { Pad = 0, @@ -34,6 +35,11 @@ pub enum MobStat { Blind = 25, SealSkill = 26, Dazzle = 28, + /// A weapon attack on the mob hurts the attacker back (v83 + /// FUN_007937e3): the value, give or take half the spread. + PCounter = 29, + /// The same for spells. + MCounter = 30, RiseByToss = 32, BodyPressure = 33, Weakness = 34, @@ -46,7 +52,7 @@ impl MobStat { /// The order the client reads the statuses in (FUN_0078b0b1), which /// isn't quite that of their bits. - pub const DECODE_ORDER: [MobStat; 29] = [ + pub const DECODE_ORDER: [MobStat; 31] = [ MobStat::Pad, MobStat::Pdr, MobStat::Mad, @@ -73,6 +79,8 @@ impl MobStat { MobStat::Blind, MobStat::SealSkill, MobStat::Dazzle, + MobStat::PCounter, + MobStat::MCounter, MobStat::RiseByToss, MobStat::BodyPressure, MobStat::Weakness, diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index 8c4cee1d8..80f159cbe 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -123,6 +123,22 @@ pub async fn set_hp(character_id: i32, hp: i16, max_hp: i16) -> Result<(), Harne .await } +/// Set the weapon attack of the logged-out character `character_id`'s +/// weapon, e.g. to hit very hard. +pub async fn set_weapon_attack(character_id: i32, watk: i16) -> Result<(), HarnessError> { + change_one_row("set weapon attack", move |conn| { + diesel::sql_query( + "UPDATE equipment_stats SET watk = $2 WHERE item_row_id = \ + (SELECT id FROM inventory_items \ + WHERE character_id = $1 AND inventory_type = -1 AND slot = -11)", + ) + .bind::(character_id) + .bind::(watk) + .execute(conn) + }) + .await +} + /// Set the logged-out character `character_id`'s STR, e.g. to hit hard. pub async fn set_str(character_id: i32, str: i16) -> Result<(), HarnessError> { change_one_row("set str", move |conn| { diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index abbe9c4fb..fce27647e 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -229,6 +229,36 @@ pub fn walk_to(x: i16, y: i16) -> Packet { build_player_move(&movement).unwrap() } +/// MOVE_LIFE for `object_id` at (x, y): wanting to attack, or playing +/// `action` (its first skill animation is 21, doubled with the facing) +/// with `skill_data` (skill | level << 8 | delay << 16). +pub fn move_mob( + object_id: i32, + (x, y): (i16, i16), + want_attack: bool, + action: u8, + skill_data: i32, +) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::MoveLife as i16).unwrap(); + p.write_int(object_id).unwrap(); + p.write_short(1).unwrap(); + p.write_byte(u8::from(want_attack)).unwrap(); + p.write_byte(action).unwrap(); + p.write_int(skill_data).unwrap(); + p.write_bytes(&[0; 13]).unwrap(); + p.write_short(x).unwrap(); + p.write_short(y).unwrap(); + p.write_byte(1).unwrap(); + p.write_byte(0).unwrap(); + p.write_short(x).unwrap(); + p.write_short(y).unwrap(); + p.write_bytes(&[0; 6]).unwrap(); + p.write_byte(4).unwrap(); + p.write_short(100).unwrap(); + p +} + /// An item or mesos dropped on the field. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MapDrop { @@ -360,9 +390,12 @@ impl Field { r.read_byte().unwrap(); self.mobs.push((oid, r.read_int().unwrap())); // Each status the server sends is a value, a skill, and a - // duration. + // duration; the counters' spreads and their chance follow. let stats = read_mob_stats(&mut r); - r.read_bytes(8 * stats.count_ones() as usize).unwrap(); + let counters = (stats >> 29 & 0b11).count_ones() as usize; + let extras = 4 * (counters + usize::from(counters > 0)); + r.read_bytes(8 * stats.count_ones() as usize + extras) + .unwrap(); let position = (r.read_short().unwrap(), r.read_short().unwrap()); self.mob_positions.insert(oid, position); r.read_byte().unwrap(); diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index 600d09116..8c0811a5e 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -133,6 +133,9 @@ pub struct Trace { /// FAME_RESPONSE: (mode, name, up, new fame); the name, up and fame are /// empty, false and 0 where the mode has none. pub fame: Vec<(u8, String, bool, i32)>, + /// BROADCAST_MSG: (kind, message) of each whose kind carries only its + /// message (0, 1, 5). + pub broadcasts: Vec<(u8, String)>, /// Mobs and drops on the current field. pub field: Field, } @@ -242,6 +245,12 @@ impl Trace { self.stat_updates.push(values); } else if op == SendOpcode::BlockedMap as i16 || op == SendOpcode::BlockedServer as i16 { self.blocked.push((op, r.read_byte().unwrap())); + } else if op == SendOpcode::BroadcastMsg as i16 { + let kind = r.read_byte().unwrap(); + if matches!(kind, 0 | 1 | 5) { + self.broadcasts + .push((kind, r.read_str_with_length().unwrap())); + } } else if op == SendOpcode::FameResponse as i16 { let mode = r.read_byte().unwrap(); let (name, up, fame) = match mode { diff --git a/integration-harness/tests/mob_counters.rs b/integration-harness/tests/mob_counters.rs new file mode 100644 index 000000000..828fdc935 --- /dev/null +++ b/integration-harness/tests/mob_counters.rs @@ -0,0 +1,106 @@ +//! Mob counters: a Lilynouch (8220005) at half its HP or under casts its +//! counter (145 level 1: weapons and spells, x 2000, y 1500, ten +//! seconds), and each attack on it after hurts the attacker 2000, give or +//! take 750. A v83 client shows its own roll and sends nothing +//! (FUN_00953595), so the server takes the HP. +//! +//! The counter waits a minute between casts, so a rerun on the same stack +//! must wait that out. + +use integration_harness::database::{set_hp, set_job, set_str, set_weapon_attack}; +use integration_harness::hunting::{attack, move_mob}; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{send, stat, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use packet::Packet; +use tokio::time::Duration; + +/// Temple of Time's Lilynouch map. +const MAP: i32 = 270_020_500; +const LILYNOUCH: i32 = 8_220_005; +const HEAL: u8 = 114; +const COUNTER: u8 = 145; +const PCOUNTER: u128 = 1 << 29; +const MCOUNTER: u128 = 1 << 30; +const MAX_HP: i16 = 30_000; + +/// The movement casting `skill` at `level` from the mob's `nth` skill +/// animation. +fn cast(object_id: i32, at: (i16, i16), nth: u8, skill: u8, level: u8) -> Packet { + let data = i32::from(skill) | i32::from(level) << 8; + move_mob(object_id, at, false, (20 + nth) << 1, data) +} + +#[tokio::test] +async fn a_counter_hurts_whoever_attacks_the_mob() { + let config = load_harness_config_or_fail().await; + + // A warrior who takes a boss to half in a few hits and outlives its + // counter. + let 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(id, 100, 120, 100).await.expect("make a warrior"); + set_str(id, 9_999).await.expect("make it strong"); + set_weapon_attack(id, 30_000) + .await + .expect("sharpen its sword"); + set_hp(id, MAX_HP, MAX_HP).await.expect("make it hardy"); + let mut player = relogin(&config).await; + let c = &mut player.connection; + + let mut t = Trace::default(); + assert_eq!(warp(c, MAP, "", &mut t).await, MAP); + until(c, &mut t, |t| !t.field.alive(LILYNOUCH).is_empty()).await; + let boss = t.field.alive(LILYNOUCH)[0]; + let at = t.field.mob_positions[&boss]; + until(c, &mut t, |t| t.field.controlled.contains(&boss)).await; + + // Take it to half (of 7.6M) in one swing, unless an earlier run on + // this stack did; nothing counters yet. + send(c, attack(boss, 1)).await; + until(c, &mut t, |t| !t.field.hp_bars.is_empty()).await; + if t.field.hp_bars[0].1 > 50 { + send(c, attack(boss, 4_000_000)).await; + until(c, &mut t, |t| t.field.hp_bars.len() > 1).await; + } + assert!(t + .field + .hp_bars + .last() + .is_some_and(|&(_, percent)| percent <= 50)); + assert!(t.stat(stat::HP).is_none()); + + // It is granted its heal or its counter; a heal waits five minutes + // after, so the counter comes next. + send(c, move_mob(boss, at, true, 0, 0)).await; + until(c, &mut t, |t| !t.field.grants.is_empty()).await; + if t.field.grants.last() == Some(&(boss, HEAL, 23)) { + send(c, cast(boss, at, 1, HEAL, 23)).await; + until(c, &mut t, |t| !t.field.heals.is_empty()).await; + tokio::time::sleep(Duration::from_millis(600)).await; + send(c, move_mob(boss, at, true, 0, 0)).await; + until(c, &mut t, |t| t.field.grants.len() > 1).await; + } + assert_eq!(t.field.grants.last(), Some(&(boss, COUNTER, 1))); + send(c, cast(boss, at, 2, COUNTER, 1)).await; + until(c, &mut t, |t| !t.field.stat_sets.is_empty()).await; + assert_eq!( + t.field.stat_sets, + [( + boss, + PCOUNTER | MCOUNTER, + 2_000, + i32::from(COUNTER) | 1 << 16 + )] + ); + + // A swing now costs 2000 HP, give or take 750. + send(c, attack(boss, 1)).await; + until(c, &mut t, |t| t.stat(stat::HP).is_some()).await; + let lost = i64::from(MAX_HP) - t.stat(stat::HP).unwrap(); + assert!((1_250..2_750).contains(&lost), "lost {lost}"); +} diff --git a/integration-harness/tests/mob_dispel_banish.rs b/integration-harness/tests/mob_dispel_banish.rs new file mode 100644 index 000000000..61c557371 --- /dev/null +++ b/integration-harness/tests/mob_dispel_banish.rs @@ -0,0 +1,105 @@ +//! A mob's Dispel and Banish reach the players in their areas. Aqua +//! Road's 8150100 (on 230040400) casts Dispel (127 level 3, sure to +//! land), which ends a beginner's Nimble Feet. A Kerning City subway +//! Shade (5090000) casts Banish (129 level 5), which tells the player why +//! in pink and sends them to the ticketing booth (103000100, `sp`). + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_skill, set_job}; +use integration_harness::hunting::{move_mob, use_skill, 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 DISPEL_MAP: i32 = 230_040_400; +const DISPELLER: i32 = 8_150_100; +const DISPEL: u8 = 127; +const SUBWAY: i32 = 103_000_105; +const SHADE: i32 = 5_090_000; +const BANISH: u8 = 129; +const TICKETING_BOOTH: i32 = 103_000_100; +const NIMBLE_FEET: i32 = 1002; +/// Speed's CANCEL_BUFF bit (second mask). +const SPEED_MASK: i64 = 0x80_0000_0000; + +/// The first mob of `mob_id` the player controls, and where it is. +async fn controlled(c: &mut MapleTestConnection, t: &mut Trace, mob_id: i32) -> (i32, (i16, i16)) { + let find = |t: &Trace| { + t.field + .controlled + .iter() + .copied() + .find(|oid| t.field.mobs.contains(&(*oid, mob_id))) + }; + until(c, t, |t| find(t).is_some()).await; + let mob = find(t).unwrap(); + (mob, t.field.mob_positions[&mob]) +} + +/// Have the mob cast whatever it is granted from its first skill +/// animation, until it is granted and casts `skill`. +async fn cast(c: &mut MapleTestConnection, t: &mut Trace, mob: i32, at: (i16, i16), skill: u8) { + for _ in 0..3 { + let granted = t.field.grants.len(); + send(c, move_mob(mob, at, true, 0, 0)).await; + until(c, t, |t| t.field.grants.len() > granted).await; + let (_, grant, level) = *t.field.grants.last().unwrap(); + let data = i32::from(grant) | i32::from(level) << 8; + send(c, move_mob(mob, at, false, 21 << 1, data)).await; + if grant == skill { + return; + } + read_for(c, t, 300).await; + } + panic!("{skill} never granted: {:?}", t.field.grants); +} + +#[tokio::test] +async fn a_mobs_dispel_ends_the_players_buffs() { + let config = load_harness_config_or_fail().await; + let 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(id, 0, 10, 100).await.expect("level up"); + grant_skill(id, NIMBLE_FEET, 1) + .await + .expect("learn Nimble Feet"); + let mut player = relogin(&config).await; + let c = &mut player.connection; + + let mut t = Trace::default(); + assert_eq!(warp(c, DISPEL_MAP, "", &mut t).await, DISPEL_MAP); + let (mob, at) = controlled(c, &mut t, DISPELLER).await; + send(c, use_skill(NIMBLE_FEET, 1)).await; + until(c, &mut t, |t| !t.buffs.is_empty()).await; + + // Standing on it, its Dispel (its Magic Up may come first) ends the + // buff. + send(c, walk_to(at.0, at.1)).await; + cast(c, &mut t, mob, at, DISPEL).await; + until(c, &mut t, |t| !t.cancelled_buffs.is_empty()).await; + assert_eq!(t.cancelled_buffs, [SPEED_MASK]); +} + +#[tokio::test] +async fn a_mobs_banish_sends_the_player_away() { + let config = load_harness_config_or_fail().await; + let mut player = login_to_world_session(&config).await.unwrap(); + let c = &mut player.connection; + + let mut t = Trace::default(); + assert_eq!(warp(c, SUBWAY, "", &mut t).await, SUBWAY); + let (mob, at) = controlled(c, &mut t, SHADE).await; + send(c, walk_to(at.0, at.1)).await; + cast(c, &mut t, mob, at, BANISH).await; + until(c, &mut t, |t| t.warps.len() > 1).await; + assert_eq!(t.warps.last(), Some(&TICKETING_BOOTH)); + assert_eq!(t.broadcasts.len(), 1); + let (kind, message) = &t.broadcasts[0]; + assert_eq!(*kind, 5); + assert!(message.ends_with("Subway Ticketing Booth."), "{message}"); +} diff --git a/integration-harness/tests/mob_mists.rs b/integration-harness/tests/mob_mists.rs new file mode 100644 index 000000000..c1484fd69 --- /dev/null +++ b/integration-harness/tests/mob_mists.rs @@ -0,0 +1,128 @@ +//! Mob mists: a Commander Skeleton in the Excavation Site's tombs +//! (7130103, Poison Mist 131 level 5: x 200, fifteen seconds, 70% to land) +//! lays a poison mist over its box. A v83 client standing in it hurts +//! itself up to x a second and says so with the skill; the server takes +//! the HP, refuses more than x, and clears the mist when its time is up. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::hunting::move_mob; +use integration_harness::login_to_world_session; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{barrier, read_for, send, stat, until, warp, Trace}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::{sleep, Duration}; + +const MIST_MAP: i32 = 101_030_108; +const CASTER: i32 = 7_130_103; +const POISON_MIST: u8 = 131; +const LEVEL: u8 = 5; +/// Its `x`: the most it hurts a second. +const X: i32 = 200; + +/// TAKE_DAMAGE as a v83 client sends it from a mist (FUN_00431783): source +/// -4, then the skill and level in place of a mob. +fn mist_damage(damage: i32, skill: u8, level: u8) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::TakeDamage as i16).unwrap(); + p.write_int(0).unwrap(); + p.write_byte(-4i8 as u8).unwrap(); + p.write_byte(4).unwrap(); + p.write_int(damage).unwrap(); + p.write_short(i16::from(skill) << 8 | i16::from(level)) + .unwrap(); + p.write_byte(2).unwrap(); + p +} + +/// Grant the mob its one skill, then cast it from its first skill +/// animation. +async fn cast(c: &mut MapleTestConnection, t: &mut Trace, mob: i32, at: (i16, i16)) { + let granted = t.field.grants.len(); + send(c, move_mob(mob, at, true, 0, 0)).await; + until(c, t, |t| t.field.grants.len() > granted).await; + assert_eq!(t.field.grants.last(), Some(&(mob, POISON_MIST, LEVEL))); + send( + c, + move_mob( + mob, + at, + false, + 21 << 1, + i32::from(POISON_MIST) | i32::from(LEVEL) << 8, + ), + ) + .await; +} + +#[tokio::test] +async fn a_mobs_poison_mist_hurts_the_players_in_it_until_it_clears() { + let config = load_harness_config_or_fail().await; + let mut player = login_to_world_session(&config).await.unwrap(); + let c = &mut player.connection; + + let mut t = Trace::default(); + assert_eq!(warp(c, MIST_MAP, "", &mut t).await, MIST_MAP); + let controls = |t: &Trace| { + t.field + .controlled + .iter() + .copied() + .find(|oid| t.field.mobs.contains(&(*oid, CASTER))) + }; + until(c, &mut t, |t| controls(t).is_some()).await; + let mob = controls(&t).unwrap(); + let at = t.field.mob_positions[&mob]; + + // It lands 70% of the time, and the mob waits its 15 second interval + // between tries. + for _ in 0..8 { + cast(c, &mut t, mob, at).await; + read_for(c, &mut t, 2_000).await; + if !t.field.mists.is_empty() { + break; + } + sleep(Duration::from_secs(15)).await; + } + let mist = t + .field + .mists + .first() + .copied() + .expect("the mist never landed"); + // A mob's mist (type 0) by its object id, starting at once over its + // box, poison for the client's resistances. + assert_eq!( + ( + mist.kind, + mist.owner_id, + mist.skill_id, + mist.level, + mist.delay + ), + (0, mob, i32::from(POISON_MIST), LEVEL, 0) + ); + let (x, y) = (i32::from(at.0), i32::from(at.1)); + assert_eq!(mist.area, (x - 135, y - 80, x + 135, y + 40)); + + // Standing in it, the client takes up to x. + send(c, mist_damage(1, POISON_MIST, LEVEL)).await; + until(c, &mut t, |t| t.stat(stat::HP).is_some()).await; + let hp = t.stat(stat::HP).unwrap(); + send(c, mist_damage(2, POISON_MIST, LEVEL)).await; + until(c, &mut t, |t| t.stat(stat::HP) == Some(hp - 2)).await; + // More than x, or from a skill that leaves no mist, is refused. + let updates = t.stat_updates.len(); + send(c, mist_damage(X + 1, POISON_MIST, LEVEL)).await; + send(c, mist_damage(1, 125, 1)).await; + barrier(c, "refused", &mut t).await; + assert_eq!(t.stat_updates.len(), updates); + + // It clears after its fifteen seconds. + sleep(Duration::from_secs(13)).await; + until(c, &mut t, |t| { + t.field.mists_removed.contains(&mist.object_id) + }) + .await; +} diff --git a/integration-harness/tests/mob_skills.rs b/integration-harness/tests/mob_skills.rs index 336dc5848..beb12ec8b 100644 --- a/integration-harness/tests/mob_skills.rs +++ b/integration-harness/tests/mob_skills.rs @@ -5,12 +5,10 @@ //! from a Jr. Cerebes. use integration_harness::database::{set_job, set_str}; -use integration_harness::hunting::attack; +use integration_harness::hunting::{attack, move_mob}; 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 net::packet::op::RecvOpcode; -use packet::io::write::PktWrite; use packet::Packet; use tokio::time::Duration; @@ -23,42 +21,13 @@ const POWER_UP_STAT: u128 = 1 << 12; const WOLF_MAP: i32 = 211_041_100; const LYCANTHROPE: i32 = 6_090_000; const HEAL: u8 = 114; +const BANISH: u8 = 129; /// A Ludibrium clocktower map with Jr. Cerebes. const CEREBES_MAP: i32 = 221_040_201; const JR_CEREBES: i32 = 4_240_000; const SUMMON: u8 = 200; const SUMMONED: i32 = 4_230_116; -/// MOVE_LIFE for `object_id` at (x, y): wanting to attack, or playing -/// `action` (its first skill animation is 21, doubled with the facing) -/// with `skill_data` (skill | level << 8 | delay << 16). -fn move_mob( - object_id: i32, - (x, y): (i16, i16), - want_attack: bool, - action: u8, - skill_data: i32, -) -> Packet { - let mut p = Packet::new_empty(); - p.write_short(RecvOpcode::MoveLife as i16).unwrap(); - p.write_int(object_id).unwrap(); - p.write_short(1).unwrap(); - p.write_byte(u8::from(want_attack)).unwrap(); - p.write_byte(action).unwrap(); - p.write_int(skill_data).unwrap(); - p.write_bytes(&[0; 13]).unwrap(); - p.write_short(x).unwrap(); - p.write_short(y).unwrap(); - p.write_byte(1).unwrap(); - p.write_byte(0).unwrap(); - p.write_short(x).unwrap(); - p.write_short(y).unwrap(); - p.write_bytes(&[0; 6]).unwrap(); - p.write_byte(4).unwrap(); - p.write_short(100).unwrap(); - p -} - /// The movement casting the `skill` at `level` from the mob's `nth` /// skill animation (1-based), `delay` ms into it. fn cast(object_id: i32, at: (i16, i16), nth: u8, skill: u8, level: u8, delay: u16) -> Packet { @@ -150,8 +119,17 @@ async fn wounded_mobs_heal_and_summon() { let at = t.field.mob_positions[&wolf]; send(c, attack(wolf, 4_000)).await; until(c, &mut t, |t| t.field.controlled.contains(&wolf)).await; - send(c, move_mob(wolf, at, true, 0, 0)).await; - until(c, &mut t, |t| !t.field.grants.is_empty()).await; + // It may be granted its Banish instead, a grant that lapses after 10 + // seconds uncast. + for _ in 0..8 { + send(c, move_mob(wolf, at, true, 0, 0)).await; + until(c, &mut t, |t| !t.field.grants.is_empty()).await; + if t.field.grants != [(wolf, BANISH, 1)] { + break; + } + t.field.grants.clear(); + tokio::time::sleep(Duration::from_millis(10_500)).await; + } assert_eq!(t.field.grants, [(wolf, HEAL, 12)]); send(c, cast(wolf, at, 2, HEAL, 12, 1_000)).await; until(c, &mut t, |t| !t.field.heals.is_empty()).await; diff --git a/integration-harness/tests/player_diseases.rs b/integration-harness/tests/player_diseases.rs index 164fbc118..a3f4a1094 100644 --- a/integration-harness/tests/player_diseases.rs +++ b/integration-harness/tests/player_diseases.rs @@ -4,13 +4,10 @@ //! one standing far away; the server ends it when it runs out. use integration_harness::connection::MapleTestConnection; -use integration_harness::hunting::walk_to; +use integration_harness::hunting::{move_mob, walk_to}; use integration_harness::login_to_world_session; use integration_harness::preconditions::load_harness_config_or_fail; use integration_harness::trace::{read_for, send, until, warp, Trace}; -use net::packet::op::RecvOpcode; -use packet::io::write::PktWrite; -use packet::Packet; use tokio::time::{sleep, Duration}; const FOLK_TOWN_MAP: i32 = 222_010_201; @@ -20,35 +17,6 @@ const SLOW: u8 = 126; /// high half first. const SLOW_STAT: i64 = 1; -/// MOVE_LIFE for `object_id` at (x, y): wanting to attack, or playing -/// `action` with `skill_data` (skill | level << 8 | delay << 16). -fn move_mob( - object_id: i32, - (x, y): (i16, i16), - want_attack: bool, - action: u8, - skill_data: i32, -) -> Packet { - let mut p = Packet::new_empty(); - p.write_short(RecvOpcode::MoveLife as i16).unwrap(); - p.write_int(object_id).unwrap(); - p.write_short(1).unwrap(); - p.write_byte(u8::from(want_attack)).unwrap(); - p.write_byte(action).unwrap(); - p.write_int(skill_data).unwrap(); - p.write_bytes(&[0; 13]).unwrap(); - p.write_short(x).unwrap(); - p.write_short(y).unwrap(); - p.write_byte(1).unwrap(); - p.write_byte(0).unwrap(); - p.write_short(x).unwrap(); - p.write_short(y).unwrap(); - p.write_bytes(&[0; 6]).unwrap(); - p.write_byte(4).unwrap(); - p.write_short(100).unwrap(); - p -} - /// Grant the mob its one skill, then cast it from its first skill /// animation. async fn slow(c: &mut MapleTestConnection, t: &mut Trace, mob: i32, at: (i16, i16)) { diff --git a/net/src/handler.rs b/net/src/handler.rs index 8ba9e5c08..3631685d6 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -376,9 +376,12 @@ pub enum FieldRequest { /// object id (action, line, and a path for an NPC that walks). NpcAction { object_id: i32, action: Vec }, /// A validated attack: `broadcast` for the others, total damage per mob, - /// and what it may put on each mob it hits and leaves alive. + /// and what it may put on each mob it hits and leaves alive. Each mob + /// with the counter (PCounter, or MCounter for a spell) hurts the player + /// back. Attack { broadcast: Packet, + counter: game::MobStat, hits: Vec<(i32, i64)>, inflicts: Vec, }, diff --git a/net/src/packet/build/world/buff.rs b/net/src/packet/build/world/buff.rs index 5be362e69..f8a00b0be 100644 --- a/net/src/packet/build/world/buff.rs +++ b/net/src/packet/build/world/buff.rs @@ -117,7 +117,11 @@ pub(crate) fn write_shown( | BuffStat::Weakness | BuffStat::Curse | BuffStat::Seduce - | BuffStat::Confuse => { + | BuffStat::Undead + | BuffStat::Confuse + | BuffStat::StopPotion + | BuffStat::StopMotion + | BuffStat::Fear => { p.write_int(buff.skill_id)?; } _ => {} diff --git a/net/src/packet/build/world/messaging.rs b/net/src/packet/build/world/messaging.rs index 9bf1992e2..e98bb7006 100644 --- a/net/src/packet/build/world/messaging.rs +++ b/net/src/packet/build/world/messaging.rs @@ -21,6 +21,28 @@ pub enum FindLocation { Channel(u8), } +/// How a v83 client shows a BROADCAST_MSG (FUN_00a22785), for the kinds +/// that carry only their message. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Broadcast { + /// "[Notice] " and the message in blue chat, or a dialog outside a + /// field. + Notice = 0, + /// A dialog. + Popup = 1, + /// Pink chat, as for a mob's Banish. + Pink = 5, +} + +/// A message from the server, shown as `kind` says. +pub fn build_broadcast(kind: Broadcast, message: &str) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::BroadcastMsg as i16)?; + packet.write_byte(kind as u8)?; + packet.write_str_with_length(message)?; + Ok(packet) +} + /// An incoming whisper. `channel` is the sender's zero-based channel. pub fn build_whisper_receive( sender_name: &str, @@ -78,6 +100,16 @@ mod tests { use super::*; use crate::codec::testing::Expect; + #[test] + fn a_broadcast_is_its_kind_and_message() { + let packet = build_broadcast(Broadcast::Pink, "Begone").unwrap(); + Expect::new(&packet.bytes) + .short(0x44) + .byte(5) + .string("Begone") + .end(); + } + #[test] fn whisper_receive_matches_reference() { let packet = build_whisper_receive("Alice", 2, false, "hello there").unwrap(); diff --git a/net/src/packet/build/world/mist.rs b/net/src/packet/build/world/mist.rs index 51657f702..3a15a49c1 100644 --- a/net/src/packet/build/world/mist.rs +++ b/net/src/packet/build/world/mist.rs @@ -1,8 +1,10 @@ use crate::{error::NetworkError, packet::op::SendOpcode}; use packet::{io::write::PktWrite, Packet}; -/// What made a mist, its second field (OdinMS-family values; v83's client -/// only tells type 3 apart, and draws by the skill). +/// What made a mist, its second field (OdinMS-family values). A v83 client +/// draws by the skill; it hurts itself in a type 0 mist (FUN_00431783), is +/// spared mob attacks in its party's type 2 (FUN_0043193a), and reads type 3 +/// as a cash item's. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MistKind { Mob = 0, @@ -22,6 +24,9 @@ pub struct MistView { pub delay: i16, /// Left, top, right, bottom. pub area: (i32, i32, i32, i32), + /// The element the damage a mob's mist does is resisted as (1 ice, 2 + /// fire, 3 lightning, 4 poison), or 0. + pub element: i32, } /// AFFECTED_AREA_CREATED (0x111, v83 FUN_00431a63): the client draws the @@ -41,7 +46,7 @@ pub fn build_spawn_mist(mist: &MistView) -> Result { p.write_int(top)?; p.write_int(right)?; p.write_int(bottom)?; - p.write_int(0)?; + p.write_int(mist.element)?; Ok(p) } @@ -67,6 +72,7 @@ mod tests { level: 30, delay: 8, area: (-200, -150, 200, 150), + element: 0, }; let p = build_spawn_mist(&mist).unwrap(); let mut want = vec![0x11, 0x01]; diff --git a/net/src/packet/build/world/mob.rs b/net/src/packet/build/world/mob.rs index 07ce0abc6..dfb1b8c1e 100644 --- a/net/src/packet/build/world/mob.rs +++ b/net/src/packet/build/world/mob.rs @@ -13,6 +13,9 @@ pub struct MobStatusView { pub skill_id: i32, /// How long it has left. pub left: Duration, + /// A counter's spread: it hurts the attacker its value, give or take + /// half this. 0 for the rest. + pub spread: i32, } /// A mob as other players see it. @@ -38,11 +41,13 @@ pub struct MobView { /// The statuses as the client reads them (FUN_0078b0b1): their flags, then /// each one's value, skill, and time left in half seconds, in the client's -/// order. +/// order; then each counter's spread, and with either the chance in +/// percent that they hurt (0 always). fn write_statuses(p: &mut Packet, statuses: &[MobStatusView]) -> Result<(), NetworkError> { write_mask(p, statuses.iter().map(|status| status.stat.bit()))?; + let find = |stat| statuses.iter().find(|status| status.stat == stat); for stat in MobStat::DECODE_ORDER { - let Some(status) = statuses.iter().find(|status| status.stat == stat) else { + let Some(status) = find(stat) else { continue; }; p.write_short(status.value)?; @@ -50,6 +55,13 @@ fn write_statuses(p: &mut Packet, statuses: &[MobStatusView]) -> Result<(), Netw let halves = status.left.as_millis().div_ceil(500); p.write_short(halves.min(i16::MAX as u128) as i16)?; } + let counters = [find(MobStat::PCounter), find(MobStat::MCounter)]; + for counter in counters.iter().flatten() { + p.write_int(counter.spread)?; + } + if counters.iter().any(Option::is_some) { + p.write_int(0)?; + } Ok(()) } @@ -244,9 +256,12 @@ pub fn build_mob_stat_reset(object_id: i32, stats: &[MobStat]) -> Result= FROM_TOUCH { + p.write_int(mob_id)?; + p.write_byte(direction)?; + p.write_short(0)?; + } p.write_int(damage)?; Ok(p) } @@ -295,6 +312,7 @@ mod tests { value: -40, skill_id: 2101003, left: Duration::from_millis(39_800), + spread: 0, }]; let packet = build_spawn_mob(&mob).unwrap(); let mut expect = Expect::new(&packet.bytes); @@ -312,6 +330,7 @@ mod tests { value, skill_id, left: Duration::from_secs(10), + spread: 0, }; let statuses = [ status(MobStat::Doom, 1, 2311005), @@ -331,6 +350,30 @@ mod tests { expect.end(); } + #[test] + fn counters_follow_with_their_spreads_and_a_chance() { + let counter = |stat, value, spread| MobStatusView { + stat, + value, + skill_id: 145 | 3 << 16, + left: Duration::from_secs(10), + spread, + }; + let statuses = [ + counter(MobStat::MCounter, 5000, 8000), + counter(MobStat::PCounter, 5000, 5000), + ]; + let packet = build_mob_stat_set(5, &statuses).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::MobStatSet as i16).int(5); + expect.long(0).long(1 << (32 + 29) | 1 << (32 + 30)); + expect.short(5000).int(145 | 3 << 16).short(20); + expect.short(5000).int(145 | 3 << 16).short(20); + expect.int(5000).int(8000).int(0); + expect.short(0).byte(0); + expect.end(); + } + #[test] fn stat_reset_names_the_lost_statuses() { let packet = build_mob_stat_reset(5, &[MobStat::Seal]).unwrap(); @@ -373,6 +416,17 @@ mod tests { expect.end(); } + #[test] + fn mist_damage_names_no_mob() { + let p = build_damage_player(7, FROM_MIST, 0, 30, 0).unwrap(); + let mut want = (SendOpcode::DamagePlayer as i16).to_le_bytes().to_vec(); + want.extend_from_slice(&7i32.to_le_bytes()); + want.push(FROM_MIST as u8); + want.extend_from_slice(&30i32.to_le_bytes()); + want.extend_from_slice(&30i32.to_le_bytes()); + assert_eq!(p.bytes, want); + } + #[test] fn a_heal_is_negative_damage() { let packet = build_heal_mob(7, 700, 900, 1000).unwrap(); diff --git a/net/src/packet/handle/world/change_map.rs b/net/src/packet/handle/world/change_map.rs index a6f28689c..ac97b141e 100644 --- a/net/src/packet/handle/world/change_map.rs +++ b/net/src/packet/handle/world/change_map.rs @@ -83,16 +83,21 @@ impl WorldHandler for ChangeMapHandler { } } -/// Send the player to `map_id`'s default spawn point (script warps). +/// Send the player to `map_id`'s portal named `portal`, or its default +/// spawn point without one (script warps, Banish). pub fn warp( player: &mut Player, map_id: i32, + portal: &str, channel_id: u8, ) -> Result, NetworkError> { - if !crate::game_data::get()?.field_exists(map_id) { - return Err(NetworkError::PacketHandlerError("Target field not found")); - } - player.move_to(map_id, 0); + let field = crate::game_data::get()? + .field(map_id) + .ok_or(NetworkError::PacketHandlerError("Target field not found"))?; + let spawn_point = field + .portal_by_name(portal) + .map_or(0, |portal| u8::try_from(portal.id).unwrap_or(0)); + player.move_to(map_id, spawn_point); Ok(vec![ WorldAction::Reply(build::world::map::build_warp_to_map( &player.stats, @@ -101,7 +106,7 @@ pub fn warp( )?), WorldAction::ChangeField { map_id, - spawn: crate::spawn_position(map_id, 0), + spawn: crate::spawn_position(map_id, spawn_point), }, ]) } diff --git a/net/src/packet/handle/world/combat.rs b/net/src/packet/handle/world/combat.rs index 1fa0799b5..350f2d3a3 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -3,11 +3,12 @@ use crate::error::NetworkError; use crate::handler::{ FieldRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, }; -use crate::packet::build::world::mob::{self, FROM_TOUCH}; +use crate::packet::build::world::mob::{self, FROM_MIST, FROM_TOUCH}; use crate::packet::build::world::stat::{self, StatUpdate}; use crate::packet::build::world::{buff, inventory}; use crate::skills; -use game::{Buff, Disease, InventoryType, Player}; +use game::buff::BuffStat; +use game::{Buff, Disease, InventoryType, MobStat, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use tracing::debug; @@ -141,8 +142,14 @@ impl WorldHandler for AttackHandler { ) }) .collect(); + let counter = if attack.kind == AttackKind::Magic { + MobStat::MCounter + } else { + MobStat::PCounter + }; result = result.with(WorldAction::Field(FieldRequest::Attack { broadcast, + counter, hits, inflicts, })); @@ -154,7 +161,7 @@ impl WorldHandler for AttackHandler { } /// `TAKE_DAMAGE`: the player was hurt by touching a mob or by one of its -/// attacks (FUN_009581a9). +/// attacks (FUN_009581a9), or by a mob's mist. pub struct TakeDamageHandler; impl WorldHandler for TakeDamageHandler { @@ -166,6 +173,10 @@ impl WorldHandler for TakeDamageHandler { r.read_byte()?; // element let damage = r.read_int()?; let player = &mut *ctx.player; + if from == FROM_MIST && player.stats.hp > 0 { + let code = r.read_short()? as u16; + return mist_damage(player, i32::from(code >> 8), i32::from(code & 0xff), damage); + } if from < FROM_TOUCH || player.stats.hp <= 0 { return Ok(HandlerResult::empty()); } @@ -242,6 +253,56 @@ impl WorldHandler for TakeDamageHandler { } } +/// The player stood in mob skill `skill`'s mist at `level`: a v83 client +/// takes its `x` a second (FUN_00431783), less any resistance, and says so +/// with the skill and level. Only HP goes; no mob is named. +/// +/// Skill 0 is Stop Motion's: standing still two seconds under it, a v83 +/// client takes its value but never the last HP (0x94b1d7) and sends the +/// same report with no skill. +fn mist_damage(player: &mut Player, skill: i32, level: i32, damage: i32) -> WorldResult { + let stop_motion = player.buffs.value(BuffStat::StopMotion); + let most = match (crate::mobs::skill_level(skill, level), stop_motion) { + (_, Some(x)) if skill == 0 && level == 0 => { + i32::from(x).min(i32::from(player.stats.hp) - 1) + } + (Some(level), _) if skill == game::mob_skill::POISON_MIST => level.x, + _ => { + debug!(skill, level, "Mist damage from a skill that leaves none"); + return Ok(HandlerResult::empty()); + } + }; + if !(0..=most).contains(&damage) { + debug!(skill, level, damage, "Mist damage out of range"); + return Ok(HandlerResult::empty()); + } + let hp = player.add_hp(-damage); + let shown = mob::build_damage_player(player.id(), FROM_MIST, 0, damage, 0)?; + let actions = HandlerResult::reply(stat::build_stat_update(false, &[StatUpdate::Hp(hp)])?) + .with(WorldAction::Field(FieldRequest::Broadcast(shown))); + Ok(if hp == 0 { + actions.with(WorldAction::Died) + } else { + actions + }) +} + +/// Mobs the player attacked countered them for `damage`: a v83 client +/// shows its own roll and sends nothing (FUN_00953595), and nobody else +/// sees it. +pub fn countered(player: &mut Player, damage: i32) -> WorldResult { + if player.stats.hp <= 0 { + return Ok(HandlerResult::empty()); + } + let hp = player.add_hp(-damage); + let actions = HandlerResult::reply(stat::build_stat_update(false, &[StatUpdate::Hp(hp)])?); + Ok(if hp == 0 { + actions.with(WorldAction::Died) + } else { + actions + }) +} + /// A mob gave the player a disease: unless they're dead or already have it, /// it starts, and the others see it if it shows. pub fn catch_disease( @@ -315,7 +376,6 @@ fn most_damage( /// a mob has, so a buff from a mob nearby isn't allowed for. fn own_buffs(mob: &game_data::mobs::MobInfo, magic: bool) -> (i32, i32) { use game::mob_skill::{effect, MobSkillEffect}; - use game::MobStat; let (add_stat, percent_stat) = if magic { (MobStat::Mad, MobStat::MagicUp) } else { @@ -363,6 +423,20 @@ mod tests { p } + /// A v83 client hurt by mob skill `skill` at `level`'s mist. + fn mist(damage: i32, skill: u8, level: u8) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::TakeDamage as i16).unwrap(); + p.write_int(0).unwrap(); + p.write_byte(FROM_MIST as u8).unwrap(); + p.write_byte(0).unwrap(); + p.write_int(damage).unwrap(); + p.write_short(i16::from(skill) << 8 | i16::from(level)) + .unwrap(); + p.write_byte(2).unwrap(); + p + } + fn handle(player: &mut game::Player, packet: Packet) -> Vec { let mut packet = packet; let mut ctx = WorldContext { @@ -388,6 +462,55 @@ mod tests { } } + #[test] + fn a_poison_mist_hurts_up_to_its_x() { + let Some(level) = crate::mobs::skill_level(131, 2) else { + return; + }; + let mut player = crate::codec::stats::sample_player(); + player.stats.hp = (2 * level.x) as i16; + let actions = handle(&mut player, mist(level.x, 131, 2)); + assert_eq!(i32::from(player.stats.hp), level.x); + assert!(matches!( + actions.as_slice(), + [ + WorldAction::Reply(_), + WorldAction::Field(FieldRequest::Broadcast(_)) + ] + )); + // More than its x, or a skill that leaves no mist, is refused. + assert!(handle(&mut player, mist(level.x + 1, 131, 2)).is_empty()); + assert!(handle(&mut player, mist(1, 125, 1)).is_empty()); + let actions = handle(&mut player, mist(level.x, 131, 2)); + assert_eq!(player.stats.hp, 0); + assert!(matches!(actions.last(), Some(WorldAction::Died))); + } + + #[test] + fn stop_motion_hurts_up_to_its_value_but_never_kills() { + let mut player = crate::codec::stats::sample_player(); + player.stats.hp = 500; + // Without it, a report with no skill is refused. + assert!(handle(&mut player, mist(330, 0, 0)).is_empty()); + + let now = std::time::Instant::now(); + let lasts = std::time::Duration::from_secs(20); + let stop_motion = 135 | 1 << 16; + player.buffs.give(Buff::new( + stop_motion, + BuffStat::StopMotion, + 330, + now, + lasts, + )); + assert!(handle(&mut player, mist(331, 0, 0)).is_empty()); + assert_eq!(handle(&mut player, mist(330, 0, 0)).len(), 2); + assert_eq!(player.stats.hp, 170); + assert!(handle(&mut player, mist(170, 0, 0)).is_empty()); + assert_eq!(handle(&mut player, mist(169, 0, 0)).len(), 2); + assert_eq!(player.stats.hp, 1); + } + #[test] fn snails_hurt_up_to_their_bound() { if crate::mobs::data().is_err() { diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 753137b3f..7326d3c93 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -41,7 +41,7 @@ pub use self::change_map::{door_warp, warp, ChangeMapHandler, DoorExit}; pub use self::char_info::CharInfoRequestHandler; pub use self::chat::AllChatHandler; pub use self::combat::{ - catch_disease, AttackHandler, MobDistanceHandler, MoveLifeHandler, NpcActionHandler, + catch_disease, countered, AttackHandler, MobDistanceHandler, MoveLifeHandler, NpcActionHandler, TakeDamageHandler, }; pub use self::door::UseDoorHandler; diff --git a/net/src/packet/handle/world/use_item.rs b/net/src/packet/handle/world/use_item.rs index 70a744382..e8971395b 100644 --- a/net/src/packet/handle/world/use_item.rs +++ b/net/src/packet/handle/world/use_item.rs @@ -6,6 +6,7 @@ use crate::packet::build::world::skill::{self, BookResult}; use crate::packet::build::world::stat::StatUpdate; use crate::packet::build::world::{inventory, stat}; use crate::skills; +use game::buff::BuffStat; use game::inventory::equip_slot; use game::{InventoryChange, InventoryType, Player, SkillRecord}; use packet::{io::read::PktRead, Packet}; @@ -14,7 +15,8 @@ use std::io::Cursor; use tracing::debug; /// `USE_ITEM`: tick, slot in the USE bag, item id. Applies the item's `spec` -/// (HP/MP, flat and percent) and consumes one. +/// (HP/MP, flat and percent) and consumes one. A v83 client sends none +/// while Stop Potion or Seduce holds (FUN_00a092fb). pub struct UseItemHandler; impl WorldHandler for UseItemHandler { @@ -44,7 +46,7 @@ impl WorldHandler for UseItemHandler { } fn use_item(player: &mut Player, slot: i16, item_id: i32) -> Option> { - if player.stats.hp <= 0 { + if player.stats.hp <= 0 || player.buffs.has(BuffStat::StopPotion) { return None; } if player.inventory.get(InventoryType::Use, slot)?.item_id != item_id { @@ -57,7 +59,7 @@ fn use_item(player: &mut Player, slot: i16, item_id: i32) -> Option Player { let mut player = crate::codec::stats::sample_player(); player.stats.job = 112; diff --git a/net/src/packet/handle/world/use_skill.rs b/net/src/packet/handle/world/use_skill.rs index 5e47a28db..bb0dc5b31 100644 --- a/net/src/packet/handle/world/use_skill.rs +++ b/net/src/packet/handle/world/use_skill.rs @@ -115,7 +115,7 @@ impl WorldHandler for UseSkillHandler { Ok(used) => { if skill_id == skills::CHAKRA { let heal = skills::chakra_heal(ctx.player, used.skill.effect.y, rand::random()); - ctx.player.add_hp(heal); + ctx.player.recover_hp(heal); } let mut result = announce(ctx.player, &used)?; if skill_id == skills::TIME_LEAP { @@ -451,7 +451,7 @@ fn heal(player: &mut Player, level: i32, party: u8, roll: f64) -> Result<(i16, i player.add_mp(-skill.effect.mp_cost); let reached = party.count_ones() as i32; let amount = skills::heal_amount(player, skill.effect.hp, reached, roll) as i32; - Ok((player.add_hp(amount), amount)) + Ok((player.recover_hp(amount), amount)) } /// A skill that was used: the buffs it gave, and the items it cost. A @@ -585,12 +585,11 @@ pub fn party_skill_reached( } let mut result = if used.skill_id == skills::HEAL || used.skill_id == skills::RESURRECTION { // Resurrection raises them with full HP (OdinMS-family servers). - let heal = if dead { - i32::from(player.stats.max_hp) + let hp = if dead { + player.add_hp(i32::from(player.stats.max_hp)) } else { - used.heal.max(0) + player.recover_hp(used.heal.max(0)) }; - let hp = player.add_hp(heal); HandlerResult::reply(stat::build_stat_update(false, &[StatUpdate::Hp(hp)])?) } else if used.skill_id == skills::TIME_LEAP { leap(player, HandlerResult::empty())? diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 2bdcdb3c6..56c25a4eb 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -28,6 +28,7 @@ pub enum SendOpcode { PartyOperation = 0x3E, SpawnPortal = 0x43, + BroadcastMsg = 0x44, CharInfo = 0x3D, BuddyList = 0x3F, MonsterBookSetCard = 0x53, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 678cad5c9..6ed5fd839 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -966,6 +966,9 @@ impl ClientActor { ServerMessage::SummonBroken { skill_id } => self.summon_broken(skill_id).await?, ServerMessage::Beholder(support) => self.beholder_support(support).await?, ServerMessage::Diseased { disease, map_id } => self.diseased(disease, map_id).await?, + ServerMessage::Countered { damage, map_id } => self.countered(damage, map_id).await?, + ServerMessage::Dispelled { map_id } => self.dispelled(map_id).await?, + ServerMessage::Banished { to, map_id } => self.banished(to, map_id).await?, ServerMessage::DoorWarp { from_map, map_id, diff --git a/runtime/src/actor/client/buff.rs b/runtime/src/actor/client/buff.rs index 525396f4d..fe57969c9 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -1,12 +1,12 @@ use super::ClientActor; use crate::error::RuntimeError; use crate::message::FieldMessage; -use game::buff::Buff; +use game::buff::{Buff, Buffs}; 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::{catch_disease, party_skill_reached}; +use net::packet::handle::world::{catch_disease, countered, party_skill_reached}; use net::summons::Support; use net::{summons, FieldRequest, PartySkill, SpawnPosition}; use packet::Packet; @@ -68,6 +68,26 @@ impl ClientActor { Ok(()) } + /// Mobs the player attacked countered them, if they are still on its + /// map and out of the Cash Shop. + pub(super) async fn countered(&mut self, damage: i32, map_id: i32) -> Result<(), RuntimeError> { + if self.in_cash_shop { + return Ok(()); + } + let Some(game) = self.game.as_mut() else { + return Ok(()); + }; + if game.player.stats.map_id != map_id { + return Ok(()); + } + let result = countered(&mut game.player, damage) + .map_err(|e| RuntimeError::Handler(e.to_string()))?; + for action in result.actions { + self.apply(action).await?; + } + Ok(()) + } + /// When the player's buffs next need `update_buffs`. pub(super) fn buffs_due(&self) -> Option { let deadline = self.game.as_ref()?.player.buffs.next_deadline()?; @@ -124,12 +144,35 @@ 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> { + self.end_buffs(Buffs::clear).await + } + + /// A mob's Dispel ends the player's skills' buffs, if they are alive, + /// still on its map and out of the Cash Shop. Diseases stay. + pub(super) async fn dispelled(&mut self, map_id: i32) -> Result<(), RuntimeError> { + if self.in_cash_shop { + return Ok(()); + } + let Some(game) = self.game.as_ref() else { + return Ok(()); + }; + if game.player.stats.map_id != map_id || game.player.stats.hp <= 0 { + return Ok(()); + } + self.end_buffs(Buffs::dispel).await + } + + /// End the buffs `end` ends, the summons with theirs. + async fn end_buffs( + &mut self, + end: fn(&mut Buffs) -> Vec, + ) -> Result<(), RuntimeError> { 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(); + let ended = end(buffs); if ended.is_empty() { return Ok(()); } diff --git a/runtime/src/actor/client/combat.rs b/runtime/src/actor/client/combat.rs index 6a3da58b3..c8dea3065 100644 --- a/runtime/src/actor/client/combat.rs +++ b/runtime/src/actor/client/combat.rs @@ -1,7 +1,9 @@ use super::quest::gain_exp_with_bonus; use super::ClientActor; use crate::error::RuntimeError; +use net::packet::build::world::messaging::{build_broadcast, Broadcast}; use net::packet::build::world::stat::{self, StatUpdate}; +use net::packet::handle::world::warp; impl ClientActor { /// This player helped kill a mob, or shares in a party member's kill: @@ -46,3 +48,34 @@ impl ClientActor { self.send(&mut packet).await } } + +impl ClientActor { + /// A mob's Banish sends the player to `to`, telling them why in pink + /// (Cosmic's changeMapBanish), if they are alive, still on its map and + /// out of the Cash Shop. + pub(super) async fn banished( + &mut self, + to: game_data::mobs::Banish, + map_id: i32, + ) -> Result<(), RuntimeError> { + if self.in_cash_shop { + return Ok(()); + } + let channel_id = self.channel_id; + let Some(game) = self.game.as_mut() else { + return Ok(()); + }; + if game.player.stats.map_id != map_id || game.player.stats.hp <= 0 { + return Ok(()); + } + let actions = warp(&mut game.player, to.map_id, &to.portal, channel_id)?; + if !to.message.is_empty() { + let mut told = build_broadcast(Broadcast::Pink, &to.message)?; + self.send(&mut told).await?; + } + for action in actions { + self.apply(action).await?; + } + Ok(()) + } +} diff --git a/runtime/src/actor/client/npc_script.rs b/runtime/src/actor/client/npc_script.rs index 80cfce926..8647e05c7 100644 --- a/runtime/src/actor/client/npc_script.rs +++ b/runtime/src/actor/client/npc_script.rs @@ -181,6 +181,7 @@ impl ClientActor { let actions = net::packet::handle::world::warp( &mut self.in_game_mut()?.player, map_id, + "", channel_id, )?; for action in actions { diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 8bb92c7e5..736a429d2 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -343,9 +343,10 @@ impl FieldActor { } FieldRequest::Attack { broadcast, + counter, hits, inflicts, - } => self.handle_attack(from, broadcast, hits, &inflicts), + } => self.handle_attack(from, broadcast, Some(counter), hits, &inflicts), FieldRequest::Mist { skill_id, level, @@ -601,10 +602,14 @@ impl FieldActor { } } + /// `from` attacked the mobs in `hits`; each one with the `counter` + /// status hurts them back, once (v83 FUN_007937e3, rolled again here: + /// its client only shows its own roll). fn handle_attack( &mut self, from: ClientId, broadcast: Packet, + counter: Option, hits: Vec<(i32, i64)>, inflicts: &[Inflict], ) { @@ -614,6 +619,18 @@ impl FieldActor { let attacker = attacker.handle.clone(); self.broadcast(Some(from), broadcast); let now = Instant::now(); + let countered: i32 = counter.map_or(0, |stat| { + hits.iter() + .map(|&(object_id, _)| self.mobs.counter(object_id, stat, rand::random())) + .filter(|&damage| damage > 0) + .sum() + }); + if countered > 0 { + attacker.send(ServerMessage::Countered { + damage: countered, + map_id: self.key.map_id, + }); + } for (object_id, damage) in hits { match self.mobs.damage(from, object_id, damage, now) { Some(Ok(percent)) => { @@ -755,6 +772,7 @@ impl FieldActor { level: level as u8, delay: MIST_DELAY, area: skills::area_box(&skill.effect, (user.avatar.x, user.avatar.y), left), + element: 0, }; let lasts = inflict.duration; let view = self.mists.add(view, from, inflict, lasts, Instant::now()); @@ -880,7 +898,8 @@ impl FieldActor { .iter() .map(|hit| (hit.object_id, i64::from(hit.damage))) .collect(); - self.handle_attack(from, broadcast, hits, limit.inflict.as_slice()); + // A v83 client counters only its own attacks. + self.handle_attack(from, broadcast, None, hits, limit.inflict.as_slice()); } /// A mob hurt `from`'s Puppet: the others see it, and it breaks at @@ -951,40 +970,53 @@ impl FieldActor { } } + /// A mob's buff takes hold with its chance on each mob it reaches, + /// counters with `spread`. + fn buff_mobs(&mut self, cast: &Cast, statuses: &[(MobStat, i16)], spread: i32, now: Instant) { + let effect = &cast.effect; + if rand::random::() * 100.0 >= f64::from(effect.prop) { + return; + } + let until = now + Duration::from_secs(effect.duration.max(0) as u64); + let skill_id = cast.skill | cast.level << 16; + for object_id in self.mobs.reached_by(cast) { + // Physical and magic immunity don't go together. + let blocked = |stat| match stat { + MobStat::PImmune => Some(MobStat::MImmune), + MobStat::MImmune => Some(MobStat::PImmune), + _ => None, + }; + let Some(mob) = self.mobs.get(object_id) else { + continue; + }; + let statuses: Vec<_> = statuses + .iter() + .filter(|(stat, _)| { + blocked(*stat).is_none_or(|other| !mob.statuses.contains_key(&other)) + }) + .copied() + .collect(); + let controller = mob.controller.unwrap_or_default(); + let given = self + .mobs + .add_statuses(object_id, &statuses, skill_id, controller, now, until); + self.mobs.spread_counters(object_id, &given, spread); + self.show_statuses(object_id, &given, now); + } + } + /// A mob skill's animation ended: buff or heal the mobs it reaches, or /// call up its summons. fn apply_cast(&mut self, cast: &Cast, now: Instant) { let effect = &cast.effect; match mob_skill::effect(cast.skill, effect.x) { Some(MobSkillEffect::Statuses(statuses)) => { - if rand::random::() * 100.0 >= f64::from(effect.prop) { - return; - } - let until = now + Duration::from_secs(effect.duration.max(0) as u64); - let skill_id = cast.skill | cast.level << 16; - for object_id in self.mobs.reached_by(cast) { - // Physical and magic immunity don't go together. - let blocked = |stat| match stat { - MobStat::PImmune => Some(MobStat::MImmune), - MobStat::MImmune => Some(MobStat::PImmune), - _ => None, - }; - let Some(mob) = self.mobs.get(object_id) else { - continue; - }; - let statuses: Vec<_> = statuses - .iter() - .filter(|(stat, _)| { - blocked(*stat).is_none_or(|other| !mob.statuses.contains_key(&other)) - }) - .copied() - .collect(); - let controller = mob.controller.unwrap_or_default(); - let given = self - .mobs - .add_statuses(object_id, &statuses, skill_id, controller, now, until); - self.show_statuses(object_id, &given, now); - } + self.buff_mobs(cast, &statuses, 0, now); + } + Some(MobSkillEffect::Counters(stats)) => { + let value = i16::try_from(effect.x).unwrap_or(i16::MAX); + let statuses: Vec<_> = stats.iter().map(|&stat| (stat, value)).collect(); + self.buff_mobs(cast, &statuses, effect.y, now); } Some(MobSkillEffect::Heal) => { for object_id in self.mobs.reached_by(cast) { @@ -1027,6 +1059,40 @@ impl FieldActor { } } } + Some(MobSkillEffect::Mist) => { + if rand::random::() * 100.0 >= f64::from(effect.prop) { + return; + } + // It lays its mist over its area; each client in it hurts + // itself (v83 FUN_00431783), so it puts nothing on mobs. + let (Some(caster), Some(area)) = + (self.mobs.get(cast.object_id), self.mobs.area(cast)) + else { + return; + }; + let view = MistView { + object_id: 0, + kind: MistKind::Mob, + owner_id: cast.object_id, + skill_id: cast.skill, + level: cast.level as u8, + delay: 0, + area: (area.left, area.top, area.right, area.bottom), + element: effect.element, + }; + let inflict = Inflict { + skill_id: cast.skill, + prop: 100, + duration: Duration::from_secs(effect.duration.max(0) as u64), + statuses: Vec::new(), + }; + let owner = caster.controller.unwrap_or_default(); + let lasts = inflict.duration; + let view = self.mists.add(view, owner, inflict, lasts, now); + if let Ok(packet) = build_spawn_mist(&view) { + self.broadcast(None, packet); + } + } Some(MobSkillEffect::Disease(_)) => { if rand::random::() * 100.0 >= f64::from(effect.prop) { return; @@ -1036,35 +1102,70 @@ impl FieldActor { else { return; }; - // Everyone in its area, or without one its controller - // (Cosmic's MobSkill.applyEffect). - let Some(caster) = self.mobs.get(cast.object_id) else { + let map_id = self.key.map_id; + self.tell_reached(cast, |_| Some(ServerMessage::Diseased { disease, map_id })); + } + Some(MobSkillEffect::Dispel) => { + if rand::random::() * 100.0 >= f64::from(effect.prop) { return; - }; - let reached: Vec = match self.mobs.area(cast) { - Some(area) => self - .occupants - .iter() - .filter(|(_, occupant)| { - area.contains((occupant.avatar.x, occupant.avatar.y)) - }) - .map(|(&client_id, _)| client_id) - .collect(), - None => caster.controller.into_iter().collect(), - }; - for client_id in reached { - if let Some(occupant) = self.occupants.get(&client_id) { - occupant.handle.send(ServerMessage::Diseased { - disease, - map_id: self.key.map_id, - }); - } } + let map_id = self.key.map_id; + self.tell_reached(cast, |_| Some(ServerMessage::Dispelled { map_id })); + } + Some(MobSkillEffect::Banish) => { + if rand::random::() * 100.0 >= f64::from(effect.prop) { + return; + } + let Some(to) = self + .mobs + .get(cast.object_id) + .and_then(|mob| mob.info.banish.clone()) + else { + return; + }; + // Up to `count` of them (inferred from the data: Cosmic + // sends everyone in reach). + let most = usize::try_from(effect.count) + .ok() + .filter(|&most| most > 0) + .unwrap_or(usize::MAX); + let map_id = self.key.map_id; + self.tell_reached(cast, |nth| { + (nth < most).then(|| ServerMessage::Banished { + to: to.clone(), + map_id, + }) + }); } None => {} } } + /// Send what `message` gives for the `nth` player a mob's cast + /// reaches: everyone in its area, or without one its controller + /// (Cosmic's MobSkill.applyEffect). + fn tell_reached(&self, cast: &Cast, message: impl Fn(usize) -> Option) { + let Some(caster) = self.mobs.get(cast.object_id) else { + return; + }; + let reached: Vec = match self.mobs.area(cast) { + Some(area) => self + .occupants + .iter() + .filter(|(_, occupant)| area.contains((occupant.avatar.x, occupant.avatar.y))) + .map(|(&client_id, _)| client_id) + .collect(), + None => caster.controller.into_iter().collect(), + }; + for (nth, client_id) in reached.into_iter().enumerate() { + let (Some(occupant), Some(message)) = (self.occupants.get(&client_id), message(nth)) + else { + continue; + }; + occupant.handle.send(message); + } + } + /// Show everyone the statuses a mob was just given. fn show_statuses(&self, object_id: i32, given: &[MobStat], now: Instant) { let Some(mob) = self.mobs.get(object_id) else { diff --git a/runtime/src/actor/field_mists.rs b/runtime/src/actor/field_mists.rs index 66849af60..e3429ba2d 100644 --- a/runtime/src/actor/field_mists.rs +++ b/runtime/src/actor/field_mists.rs @@ -126,6 +126,7 @@ mod tests { level: 30, delay: 8, area: (-200, -150, 200, 150), + element: 0, }; set.add(view, 1, inflict, Duration::from_secs(40), now) } diff --git a/runtime/src/actor/field_mobs.rs b/runtime/src/actor/field_mobs.rs index e6864c444..6bf306e5d 100644 --- a/runtime/src/actor/field_mobs.rs +++ b/runtime/src/actor/field_mobs.rs @@ -111,6 +111,8 @@ pub struct MobStatus { pub until: Instant, /// When a status that hurts the mob next does. pub next_hurt: Instant, + /// A counter's spread (`MobStatusView::spread`), 0 for the rest. + pub spread: i32, } /// A mob's attackN is action 12 + N - 1, sent as action << 1 | facing left @@ -310,6 +312,7 @@ impl LiveMob { value: status.value, skill_id: status.skill_id, left: status.until.saturating_duration_since(now), + spread: status.spread, }) .collect() } @@ -684,6 +687,7 @@ impl MobSet { from, until, next_hurt: now + HURT_INTERVAL, + spread: 0, }; mob.statuses.insert(stat, status); stat @@ -691,6 +695,37 @@ impl MobSet { .collect() } + /// Give the counters a mob has among `stats` their spread. + pub fn spread_counters(&mut self, object_id: i32, stats: &[MobStat], spread: i32) { + let Some(mob) = self.live.get_mut(&object_id) else { + return; + }; + for stat in stats { + if let Some(status) = mob.statuses.get_mut(stat) { + status.spread = spread; + } + } + } + + /// What a mob's counter (`stat`: MCounter for a spell, PCounter for + /// the rest) hurts its attacker with for one attack, or 0: like the v83 + /// client (FUN_007937e3), its value, then with a spread, plus `roll` + /// modulo the spread less half of it. + pub fn counter(&self, object_id: i32, stat: MobStat, roll: u32) -> i32 { + let Some(status) = self + .live + .get(&object_id) + .and_then(|mob| mob.statuses.get(&stat)) + else { + return 0; + }; + let value = i32::from(status.value); + match u32::try_from(status.spread) { + Ok(spread) if spread > 0 => value + (roll % spread) as i32 - status.spread / 2, + _ => value, + } + } + /// Take these statuses off a mob; returns the ones it had. pub fn remove_statuses(&mut self, object_id: i32, stats: &[MobStat]) -> Vec { let Some(mob) = self.live.get_mut(&object_id) else { @@ -1197,6 +1232,28 @@ mod tests { ); } + #[test] + fn counters_hurt_their_value_give_or_take_half_the_spread() { + let now = Instant::now(); + let mut mobs = MobSet::new([snail_spawn(100)]); + let snail = mobs.respawn(now)[0]; + let until = now + Duration::from_secs(10); + assert_eq!(mobs.counter(snail, MobStat::PCounter, 7), 0); + let counters = [(MobStat::PCounter, 220), (MobStat::MCounter, 220)]; + mobs.add_statuses(snail, &counters, 145 | 5 << 16, 1, now, until); + assert_eq!(mobs.counter(snail, MobStat::MCounter, 7), 220); + mobs.spread_counters(snail, &[MobStat::PCounter], 98); + assert_eq!(mobs.counter(snail, MobStat::PCounter, 0), 220 - 49); + assert_eq!(mobs.counter(snail, MobStat::PCounter, 97 + 98), 220 + 48); + assert_eq!(mobs.counter(snail, MobStat::MCounter, 97), 220); + let view = mobs.get(snail).unwrap().status_views(now); + let spread = |stat| view.iter().find(|v| v.stat == stat).unwrap().spread; + assert_eq!( + (spread(MobStat::PCounter), spread(MobStat::MCounter)), + (98, 0) + ); + } + #[test] fn poison_hurts_each_second_but_never_kills() { let now = Instant::now(); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index f894363ae..db388f02a 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -124,6 +124,20 @@ pub enum ServerMessage { disease: game::Disease, map_id: i32, }, + /// A mob's Dispel on `map_id` reached this player. + Dispelled { + map_id: i32, + }, + /// A mob's Banish on `map_id` sends this player away. + Banished { + to: game_data::mobs::Banish, + map_id: i32, + }, + /// Mobs on `map_id` this player attacked countered it for `damage`. + Countered { + damage: i32, + map_id: i32, + }, /// The Mystic Door this player entered on `from_map` leads to `map_id`. DoorWarp { from_map: i32,