From a78d95cacc34d1997be0314c80103fa3358fa563 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 16:55:29 -0400 Subject: [PATCH 1/4] L160: mob Poison Mist hurts the players standing in it A granted MobSkill 131 cast now lays a type 0 mist over the mob's lt/rb box for the level's time, owned by the mob and carrying the level's elemAttr as its element. As the v83 client does (FUN_00431783 from CUserLocal's update), HeavenClient hurts the player x once a second while they stand in a started mob mist without touching a mob. It shows the number and sends TAKE_DAMAGE from -4 with the skill and level. The server takes damage only for 131 at a known level and up to x, then tells the field through DAMAGE_PLAYER, which drops the mob fields for sources below -1. HeavenClient also learns the v83 Smokescreen rule (FUN_0043193a): a started type 2 area owned by the player or a party member keeps mob attacks, touch and mists off them. The server does not spawn those yet. Mist layout tests cover the hit and shelter lookups, and the harness mob_mists test covers the cast, the area, the damage limits and the expiry. --- HeavenClient/Gameplay/MapleMap/MapMists.cpp | 50 +++++- HeavenClient/Gameplay/MapleMap/MapMists.h | 19 ++- HeavenClient/Gameplay/MapleMap/MistLayout.h | 88 ++++++++++ HeavenClient/Gameplay/Stage.cpp | 33 +++- HeavenClient/Gameplay/Stage.h | 2 + .../Net/Handlers/MapObjectHandlers.cpp | 9 +- .../Net/Packets/AttackAndSkillPackets.h | 15 +- HeavenClient/tests/mist_layout.cpp | 30 ++++ game-data/src/skills.rs | 13 ++ game/src/mob_skill.rs | 10 +- integration-harness/tests/mob_mists.rs | 156 ++++++++++++++++++ net/src/packet/build/world/mist.rs | 12 +- net/src/packet/build/world/mob.rs | 24 ++- net/src/packet/handle/world/combat.rs | 72 +++++++- runtime/src/actor/field.rs | 35 ++++ runtime/src/actor/field_mists.rs | 1 + 16 files changed, 542 insertions(+), 27 deletions(-) create mode 100644 integration-harness/tests/mob_mists.rs 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/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/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index 8d60cf7a7..dd868bba0 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -229,11 +229,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 +253,34 @@ 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; + } + } } 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..8ce719e1f 100644 --- a/HeavenClient/Gameplay/Stage.h +++ b/HeavenClient/Gameplay/Stage.h @@ -228,6 +228,8 @@ 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; 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/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 3a224a16b..65ffb653a 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,18 @@ 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). The last byte is always 2. + TakeDamagePacket(uint8_t element, int32_t damage, uint16_t code) : OutPacket(OutPacket::Opcode::TAKE_DAMAGE) + { + write_time(); + write_byte(MIST); + write_byte(element); + write_int(damage); + write_short(static_cast(code)); + write_byte(2); + } }; // Packet which notifies the server of a skill usage. 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/game-data/src/skills.rs b/game-data/src/skills.rs index 697e81165..bdd85a0b1 100644 --- a/game-data/src/skills.rs +++ b/game-data/src/skills.rs @@ -102,6 +102,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 { @@ -223,6 +226,13 @@ impl SkillData { 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 +319,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/mob_skill.rs b/game/src/mob_skill.rs index 02fc1f88d..593ba7f60 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,15 @@ 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, /// A disease on every player in its area. Disease(BuffStat), } /// 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). +/// yet: the other diseases (127, 129, 130, 133-136: dispel, banish, area +/// fire, ...) and the damage counters (143-145). /// /// 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 +50,7 @@ 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), _ => return disease(skill).map(MobSkillEffect::Disease), }; Some(MobSkillEffect::Statuses(vec![(stat, x)])) @@ -91,6 +96,7 @@ 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)) diff --git a/integration-harness/tests/mob_mists.rs b/integration-harness/tests/mob_mists.rs new file mode 100644 index 000000000..4b2bc1a25 --- /dev/null +++ b/integration-harness/tests/mob_mists.rs @@ -0,0 +1,156 @@ +//! 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::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; + +/// 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 +} + +/// 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/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..115565e35 100644 --- a/net/src/packet/build/world/mob.rs +++ b/net/src/packet/build/world/mob.rs @@ -244,9 +244,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) } @@ -373,6 +378,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/combat.rs b/net/src/packet/handle/world/combat.rs index 1fa0799b5..1eb797e1c 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -3,7 +3,7 @@ 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; @@ -154,7 +154,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 +166,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 +246,32 @@ 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. +fn mist_damage(player: &mut Player, skill: i32, level: i32, damage: i32) -> WorldResult { + let most = match crate::mobs::skill_level(skill, level) { + 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 + }) +} + /// 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( @@ -363,6 +393,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 +432,30 @@ 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 snails_hurt_up_to_their_bound() { if crate::mobs::data().is_err() { diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 8bb92c7e5..9453812e3 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -755,6 +755,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()); @@ -1027,6 +1028,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; 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) } From 25454e6b74b74b2fd8e09dd05b72e5616b7d54e7 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 17:36:37 -0400 Subject: [PATCH 2/4] L175: mob counters (143, 144, 145) A mob's Weapon, Magic or full counter puts PCounter (bit 29) and/or MCounter (bit 30) on it, written after the statuses as each counter's spread and one shared chance, as the v83 client reads them. Each player attack (not a summon's) on a countering mob costs the attacker the value give or take half the spread, magic against MCounter. The client shows its own roll and sends nothing, so the server takes the HP. HeavenClient parses the new stats and shows the counter number over the player for each countering target it hits. Harness mob_counters casts Lilynouch's 145 and checks the HP lost to one swing. --- HeavenClient/Gameplay/Combat/Combat.cpp | 15 ++ HeavenClient/Gameplay/Combat/Combat.h | 5 + HeavenClient/Gameplay/MapleMap/MapMobs.cpp | 8 + HeavenClient/Gameplay/MapleMap/MapMobs.h | 4 + HeavenClient/Gameplay/MapleMap/Mob.cpp | 11 ++ HeavenClient/Gameplay/MapleMap/Mob.h | 4 + .../Net/Handlers/Helpers/MobStatParser.cpp | 34 ++++- .../Net/Handlers/Helpers/MobStatParser.h | 26 +++- HeavenClient/tests/mob_stat_parser.cpp | 13 +- game/src/mob_skill.rs | 21 ++- game/src/mob_stat.rs | 14 +- integration-harness/src/database.rs | 16 ++ integration-harness/src/hunting.rs | 7 +- integration-harness/tests/mob_counters.rs | 137 ++++++++++++++++++ net/src/handler.rs | 5 +- net/src/packet/build/world/mob.rs | 42 +++++- net/src/packet/handle/world/combat.rs | 25 +++- net/src/packet/handle/world/mod.rs | 2 +- runtime/src/actor/client.rs | 1 + runtime/src/actor/client/buff.rs | 22 ++- runtime/src/actor/field.rs | 91 ++++++++---- runtime/src/actor/field_mobs.rs | 57 ++++++++ runtime/src/message.rs | 5 + 23 files changed, 513 insertions(+), 52 deletions(-) create mode 100644 integration-harness/tests/mob_counters.rs 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/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/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/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/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/src/mob_skill.rs b/game/src/mob_skill.rs index 593ba7f60..73ebaaaaa 100644 --- a/game/src/mob_skill.rs +++ b/game/src/mob_skill.rs @@ -19,13 +19,16 @@ pub enum MobSkillEffect { 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), } /// What `skill` does with its value `x`, or `None` for the ones not cast /// yet: the other diseases (127, 129, 130, 133-136: dispel, banish, area -/// fire, ...) and the damage counters (143-145). +/// fire, ...). /// /// 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 @@ -51,6 +54,14 @@ pub fn effect(skill: i32, x: i32) -> Option { 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, + ])) + } _ => return disease(skill).map(MobSkillEffect::Disease), }; Some(MobSkillEffect::Statuses(vec![(stat, x)])) @@ -109,6 +120,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..e3dab09bf 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -360,9 +360,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/tests/mob_counters.rs b/integration-harness/tests/mob_counters.rs new file mode 100644 index 000000000..15e8c99a8 --- /dev/null +++ b/integration-harness/tests/mob_counters.rs @@ -0,0 +1,137 @@ +//! 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; +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 net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +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; + +/// 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 +} + +/// 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/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/mob.rs b/net/src/packet/build/world/mob.rs index 115565e35..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(()) } @@ -300,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); @@ -317,6 +330,7 @@ mod tests { value, skill_id, left: Duration::from_secs(10), + spread: 0, }; let statuses = [ status(MobStat::Doom, 1, 2311005), @@ -336,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(); diff --git a/net/src/packet/handle/world/combat.rs b/net/src/packet/handle/world/combat.rs index 1eb797e1c..3e5f6764f 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -7,7 +7,7 @@ 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, Disease, InventoryType, MobStat, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; use tracing::debug; @@ -141,8 +141,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, })); @@ -272,6 +278,22 @@ fn mist_damage(player: &mut Player, skill: i32, level: i32, damage: i32) -> Worl }) } +/// 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( @@ -345,7 +367,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 { 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/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 678cad5c9..de5dcf3a7 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -966,6 +966,7 @@ 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::DoorWarp { from_map, map_id, diff --git a/runtime/src/actor/client/buff.rs b/runtime/src/actor/client/buff.rs index 525396f4d..0ee86c165 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -6,7 +6,7 @@ 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()?; diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 9453812e3..8edc8f569 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)) => { @@ -881,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 @@ -952,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) { 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..8ee072a62 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -124,6 +124,11 @@ pub enum ServerMessage { disease: game::Disease, 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, From be946ea259adfb5f949e0627cd97f2a4c58be4ae Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 17:50:22 -0400 Subject: [PATCH 3/4] L175: mob Dispel and Banish A mob's Dispel (127) ends every buff but the diseases of each player in its area, unsummoning summons, and Banish (129) sends up to `count` of them to the mob's `ban/banMap` portal with its `banMsg` as a type 5 BROADCAST_MSG. Both roll the skill's prop first and reach the mob's controller when the skill has no area. The warp helper now takes a portal name. HeavenClient's BROADCAST_MSG handler now follows the client per type: "[Notice]" lines in light blue, the popup, the pink type 5 line, and it reads the extras of types 3, 6 and 7 instead of dropping the packet. The mob_dispel_banish harness test has an Aqua Road mob dispel a beginner's Nimble Feet and a subway Shade banish the player to the ticketing booth. move_mob moved into the harness's hunting helpers. --- HeavenClient/Graphics/Color.h | 4 +- HeavenClient/IO/UITypes/UIChatBar.cpp | 2 +- HeavenClient/IO/UITypes/UIChatBar.h | 1 + .../Net/Handlers/MessagingHandlers.cpp | 49 +++++--- game-data/src/mobs.rs | 48 ++++++++ game-data/src/skills.rs | 3 + game/src/buff.rs | 25 +++++ game/src/mob_skill.rs | 13 ++- integration-harness/src/hunting.rs | 30 +++++ integration-harness/src/trace.rs | 9 ++ integration-harness/tests/mob_counters.rs | 33 +----- .../tests/mob_dispel_banish.rs | 105 ++++++++++++++++++ integration-harness/tests/mob_mists.rs | 30 +---- integration-harness/tests/mob_skills.rs | 48 +++----- integration-harness/tests/player_diseases.rs | 34 +----- net/src/packet/build/world/messaging.rs | 32 ++++++ net/src/packet/handle/world/change_map.rs | 17 ++- net/src/packet/op/send.rs | 1 + runtime/src/actor/client.rs | 2 + runtime/src/actor/client/buff.rs | 27 ++++- runtime/src/actor/client/combat.rs | 33 ++++++ runtime/src/actor/client/npc_script.rs | 1 + runtime/src/actor/field.rs | 79 +++++++++---- runtime/src/message.rs | 9 ++ 24 files changed, 457 insertions(+), 178 deletions(-) create mode 100644 integration-harness/tests/mob_dispel_banish.rs diff --git a/HeavenClient/Graphics/Color.h b/HeavenClient/Graphics/Color.h index 4f132df34..84973f8ff 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] 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/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/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 bdd85a0b1..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), @@ -222,6 +224,7 @@ 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")?, diff --git a/game/src/buff.rs b/game/src/buff.rs index 4a19b9e5b..bc5eaa881 100644 --- a/game/src/buff.rs +++ b/game/src/buff.rs @@ -330,6 +330,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 +384,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/mob_skill.rs b/game/src/mob_skill.rs index 73ebaaaaa..0c500925f 100644 --- a/game/src/mob_skill.rs +++ b/game/src/mob_skill.rs @@ -24,11 +24,14 @@ pub enum MobSkillEffect { 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, 130, 133-136: dispel, banish, area -/// fire, ...). +/// yet (130, 133-136). /// /// 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 @@ -62,6 +65,8 @@ pub fn effect(skill: i32, x: i32) -> Option { MobStat::MCounter, ])) } + 127 => return Some(MobSkillEffect::Dispel), + 129 => return Some(MobSkillEffect::Banish), _ => return disease(skill).map(MobSkillEffect::Disease), }; Some(MobSkillEffect::Statuses(vec![(stat, x)])) @@ -112,7 +117,9 @@ mod tests { 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(133, 1), None); let slow = inflicted(126, 2, 80, 10).unwrap(); assert_eq!( (slow.stat, slow.value, slow.skill_id, slow.lasts), diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index e3dab09bf..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 { 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 index 15e8c99a8..828fdc935 100644 --- a/integration-harness/tests/mob_counters.rs +++ b/integration-harness/tests/mob_counters.rs @@ -8,12 +8,10 @@ //! must wait that out. use integration_harness::database::{set_hp, set_job, set_str, set_weapon_attack}; -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::{send, stat, until, warp, Trace}; use integration_harness::{login_to_world_session, relogin}; -use net::packet::op::RecvOpcode; -use packet::io::write::PktWrite; use packet::Packet; use tokio::time::Duration; @@ -26,35 +24,6 @@ const PCOUNTER: u128 = 1 << 29; const MCOUNTER: u128 = 1 << 30; const MAX_HP: i16 = 30_000; -/// 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 -} - /// 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 { 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 index 4b2bc1a25..c1484fd69 100644 --- a/integration-harness/tests/mob_mists.rs +++ b/integration-harness/tests/mob_mists.rs @@ -5,6 +5,7 @@ //! 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}; @@ -20,35 +21,6 @@ const LEVEL: u8 = 5; /// Its `x`: the most it hurts a second. const X: i32 = 200; -/// 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 -} - /// 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 { 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/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/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/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 de5dcf3a7..6ed5fd839 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -967,6 +967,8 @@ impl ClientActor { 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 0ee86c165..fe57969c9 100644 --- a/runtime/src/actor/client/buff.rs +++ b/runtime/src/actor/client/buff.rs @@ -1,7 +1,7 @@ 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}; @@ -144,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 8edc8f569..736a429d2 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -1102,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/message.rs b/runtime/src/message.rs index 8ee072a62..db388f02a 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -124,6 +124,15 @@ 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, From 386329001a5e4f22bc7d8fe00afbb6c1ef62b888 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 18:10:59 -0400 Subject: [PATCH 4/4] L175: mob diseases Undead, Stop Potion, Stop Motion and Fear Mob skills 133-136 now inflict their diseases. Undead shares Puppet's buff bit 46 and is told apart by its mob skill, on both sides. Stop Potion, Stop Motion and Fear are bits 75-77, each shown with one int; the client had read bit 77 as an int and two bytes. The server refuses potions under Stop Potion and halves recovered HP (potions, Heal, Chakra) under Undead. Stop Motion's damage arrives as a mist hit with no skill code, and the server takes up to its value but never the last HP. The client gives the notices, refuses item use under Stop Potion or Seduce, and hurts a player who stands still two seconds under Stop Motion, with the v83 chat line and packet. Under Fear it blacks out the screen except Effect/MapEff.img/Viewrange/3 around the player, using a new cutout shader mode. --- HeavenClient/Character/Buff.cpp | 3 ++ HeavenClient/Character/Buff.h | 6 +++ HeavenClient/Character/Char.cpp | 5 ++ HeavenClient/Character/Char.h | 1 + HeavenClient/Character/Diseases.h | 48 +++++++++++++++++-- HeavenClient/Character/Player.cpp | 45 ++++++++++++++++- HeavenClient/Character/Player.h | 6 +++ HeavenClient/Gameplay/Stage.cpp | 38 +++++++++++++++ HeavenClient/Gameplay/Stage.h | 5 ++ HeavenClient/Graphics/Color.h | 8 ++++ HeavenClient/Graphics/GraphicsGL.cpp | 4 ++ HeavenClient/IO/UITypes/UIItemInventory.cpp | 4 +- .../Net/Handlers/Helpers/BuffParser.cpp | 17 +++---- HeavenClient/Net/Handlers/PlayerHandlers.cpp | 5 ++ .../Net/Packets/AttackAndSkillPackets.h | 7 +-- HeavenClient/tests/buff_parser.cpp | 15 ++++++ HeavenClient/tests/diseases.cpp | 16 +++++++ game/src/buff.rs | 24 ++++++++-- game/src/character.rs | 10 ++++ game/src/mob_skill.rs | 14 ++++-- net/src/packet/build/world/buff.rs | 6 ++- net/src/packet/handle/world/combat.rs | 38 ++++++++++++++- net/src/packet/handle/world/use_item.rs | 44 +++++++++++++++-- net/src/packet/handle/world/use_skill.rs | 11 ++--- 24 files changed, 346 insertions(+), 34 deletions(-) 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/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index dd868bba0..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) @@ -281,6 +308,17 @@ namespace ms 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 8ce719e1f..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, @@ -236,6 +239,8 @@ namespace ms // 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 84973f8ff..91c9fd8af 100644 --- a/HeavenClient/Graphics/Color.h +++ b/HeavenClient/Graphics/Color.h @@ -222,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/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/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 65ffb653a..9120dbb70 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -142,15 +142,16 @@ namespace ms 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). The last byte is always 2. - TakeDamagePacket(uint8_t element, int32_t damage, uint16_t code) : OutPacket(OutPacket::Opcode::TAKE_DAMAGE) + // 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(2); + write_byte(last); } }; 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/game/src/buff.rs b/game/src/buff.rs index bc5eaa881..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 { 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 0c500925f..b0fc6a3fe 100644 --- a/game/src/mob_skill.rs +++ b/game/src/mob_skill.rs @@ -30,8 +30,8 @@ pub enum MobSkillEffect { Banish, } -/// What `skill` does with its value `x`, or `None` for the ones not cast -/// yet (130, 133-136). +/// 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 @@ -84,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, }) } @@ -119,7 +123,11 @@ mod tests { ); assert_eq!(effect(127, 0), Some(MobSkillEffect::Dispel)); assert_eq!(effect(129, 0), Some(MobSkillEffect::Banish)); - assert_eq!(effect(133, 1), None); + 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), 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/handle/world/combat.rs b/net/src/packet/handle/world/combat.rs index 3e5f6764f..350f2d3a3 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -7,6 +7,7 @@ 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::BuffStat; use game::{Buff, Disease, InventoryType, MobStat, Player}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; @@ -255,9 +256,17 @@ 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 most = match crate::mobs::skill_level(skill, level) { - Some(level) if skill == game::mob_skill::POISON_MIST => level.x, + 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()); @@ -477,6 +486,31 @@ mod tests { 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/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())?