From 002e24a570a4d7cf0ea4793a91a415902b37c55e Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 00:52:30 -0400 Subject: [PATCH] c24 Hypnotize: 5221009 hits for the plain blow (no damage key, like Disorder and Taunt) and puts Dazzle (MobStat bit 28) on each mob it hurts by its prop for its time; bosses shrug it off --- agent_docs/combat.md | 9 +++-- game/src/mob_stat.rs | 7 +++- integration-harness/tests/hypnotize.rs | 50 ++++++++++++++++++++++++++ net/src/combat.rs | 15 +++++--- net/src/skills.rs | 21 +++++++++++ 5 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 integration-harness/tests/hypnotize.rs diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 6b8e5422..f214dab3 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -943,7 +943,7 @@ carry the statuses a mob has. Entries come in the client's decode order Dispel take mob buffs off instead. - An attack's hits carry `skills::on_hit`: the attack skill's own status (stuns, freezes, poisons, Disorder's lower attack and defence, Taunt's - higher defence) and a learned Venom's poison. Each mob hit for more than + higher defence, Hypnotize's Dazzle) and a learned Venom's poison. Each mob hit for more than 0 rolls each. Elemental Boost makes Ice Splitter's freeze last its `y` seconds longer; the extra poison its description promises for Flamethrower is not modelled. @@ -951,7 +951,12 @@ carry the statuses a mob has. Entries come in the client's decode order that dies with it gives its value in percent more EXP and makes each of its drops that much likelier (`Kill::showdown`, `loot::roll`; the OdinMS-family rule). -- Bosses shrug off Stun, Freeze, Seal, Doom and Web. A mob immune to poison +- Hypnotize (5221009) has no `damage` and hits for the plain blow, like + Disorder and Taunt. Its Dazzle (bit 28) stops the mob attacking players + on the client. In v83 other mobs may then hit it, reported by their + controller as MOB_DAMAGE_MOB (0xC2); the server has no handler for that + yet. +- Bosses shrug off Stun, Freeze, Seal, Doom, Web and Dazzle. A mob immune to poison (`elemAttr` S1) or ice (I1) shrugs off Poison or Freeze. - Poison, Venom and Ambush take HP each second on the field tick (`MobSet::hurt_by_statuses`), the mob's `fixedDamage` instead if it has diff --git a/game/src/mob_stat.rs b/game/src/mob_stat.rs index 72f147eb..9503c56e 100644 --- a/game/src/mob_stat.rs +++ b/game/src/mob_stat.rs @@ -120,7 +120,12 @@ impl MobStat { pub fn spares_bosses(self) -> bool { matches!( self, - MobStat::Stun | MobStat::Freeze | MobStat::Seal | MobStat::Doom | MobStat::Web + MobStat::Stun + | MobStat::Freeze + | MobStat::Seal + | MobStat::Doom + | MobStat::Web + | MobStat::Dazzle ) } } diff --git a/integration-harness/tests/hypnotize.rs b/integration-harness/tests/hypnotize.rs new file mode 100644 index 00000000..d5227e0d --- /dev/null +++ b/integration-harness/tests/hypnotize.rs @@ -0,0 +1,50 @@ +//! Hypnotize: a Corsair's close gun shot with no `damage` of its own hits +//! for the plain blow and dazzles the mob (MobStat bit 28), at level 20 +//! always. A shot that misses puts nothing on it. + +use integration_harness::database::{grant_skill, replace_item, set_job}; +use integration_harness::hunting::skill_attack; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{step, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use tokio::time::Duration; + +const HUNTING_GROUND: i32 = 40_001; +const SNAIL: i32 = 100100; +const CORSAIR: i16 = 522; +const HYPNOTIZE: i32 = 5221009; +const GUN: i32 = 1492000; +const EQUIPPED: i16 = -1; +const WEAPON: i16 = -11; +const DAZZLE: u128 = 1 << 28; + +#[tokio::test] +async fn hypnotize_dazzles_the_mob_it_hits() { + 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, CORSAIR, 120, 1000).await.expect("Corsair"); + grant_skill(id, HYPNOTIZE, 20).await.expect("Hypnotize"); + replace_item(id, EQUIPPED, WEAPON, GUN) + .await + .expect("give a gun"); + + let mut session = relogin(&config).await; + let c = &mut session.connection; + let mut h = Trace::default(); + assert_eq!(warp(c, HUNTING_GROUND, "", &mut h).await, HUNTING_GROUND); + until(c, &mut h, |t| t.field.alive(SNAIL).len() >= 2).await; + let snails = h.field.alive(SNAIL); + + let t = step(c, "miss", skill_attack(HYPNOTIZE, snails[1], 0)).await; + assert!(!t.stat_updates.is_empty(), "the shot is taken"); + assert!(t.field.stat_sets.is_empty(), "a miss dazzles nothing"); + + let t = step(c, "shot", skill_attack(HYPNOTIZE, snails[0], 1)).await; + assert!(!t.stat_updates.is_empty(), "the shot is taken"); + assert_eq!(t.field.stat_sets, [(snails[0], DAZZLE, 1, HYPNOTIZE)]); +} diff --git a/net/src/combat.rs b/net/src/combat.rs index dbf007c2..ef010cc6 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -6,7 +6,7 @@ use crate::charge; use crate::combo; use crate::energy; use crate::meso_explosion::{self, Blast, Explosion, MESO_EXPLOSION}; -use crate::skills::{self, Learned}; +use crate::skills::{self, Learned, HYPNOTIZE}; use crate::{error::NetworkError, packet::op::SendOpcode}; use game::inventory::equip_slot; use game::{BuffStat, InventoryType, Player}; @@ -790,7 +790,9 @@ fn sacrifice_cost(attack: &Attack, x: i32, hp: i16) -> i32 { fn damage_percent(skill: &Learned) -> Option { match (skill.effect.damage, skill.skill_id) { (0, ARROW_BOMB) => Some(skill.effect.x), - (0, 4001002 | 14001002 | 4121003 | 4221003) => Some(100), + // Disorder, Taunt, and Hypnotize have no `damage`: the client hits + // for the plain blow. + (0, 4001002 | 14001002 | 4121003 | 4221003 | HYPNOTIZE) => Some(100), (0, _) => None, (damage, _) => Some(damage), } @@ -1854,7 +1856,7 @@ mod tests { } #[test] - fn disorder_and_taunt_hit_for_the_base_cap() { + fn disorder_taunt_and_hypnotize_hit_for_the_base_cap() { if skills::data().is_err() { return; } @@ -1865,8 +1867,13 @@ mod tests { player.stats.mp = 100; learn(&mut player, 4001002, 20); learn(&mut player, 4121003, 30); + learn(&mut player, HYPNOTIZE, 20); let cap = (max_base_damage(&player, &hit(AttackKind::Close, 4001002, 0)) * 2).max(10); - for (kind, skill) in [(AttackKind::Close, 4001002), (AttackKind::Ranged, 4121003)] { + for (kind, skill) in [ + (AttackKind::Close, 4001002), + (AttackKind::Ranged, 4121003), + (AttackKind::Close, HYPNOTIZE), + ] { assert!(validate(&hit(kind, skill, cap), &player).is_ok()); assert_eq!( validate(&hit(kind, skill, cap + 1), &player).unwrap_err(), diff --git a/net/src/skills.rs b/net/src/skills.rs index 97cd639f..01601a5e 100644 --- a/net/src/skills.rs +++ b/net/src/skills.rs @@ -760,11 +760,17 @@ fn hit_statuses(skill: &Learned) -> Option> { value(MobStat::Pdr, effect.x), value(MobStat::Mdr, effect.x), ], + // Hypnotize: the mob stops attacking players and other mobs may hit + // it (MobStat bit 28, 0x6663d2), by its `prop` for its `time`. + HYPNOTIZE => one(MobStat::Dazzle), _ => return None, }; Some(statuses) } +/// The Corsair's Hypnotize, a close gun shot that puts Dazzle on the mob. +pub const HYPNOTIZE: i32 = 5221009; + /// Elemental Boost, which adds its `damage` percent to Flamethrower's and /// Ice Splitter's (0x79004c) and, as its description says, its `y` seconds /// to Ice Splitter's freeze. @@ -1661,6 +1667,21 @@ mod tests { ); } + #[test] + fn hypnotize_dazzles_by_its_prop_for_its_time() { + let player = knowing(&[]); + let hypnotize = at_level(HYPNOTIZE, 1).unwrap(); + let [inflict] = &on_hit(&player, Some(&hypnotize), false, 0.5)[..] else { + panic!("one inflict"); + }; + assert_eq!(inflict.skill_id, HYPNOTIZE); + assert_eq!(inflict.prop, 43); + assert_eq!(inflict.duration, Duration::from_secs(21)); + assert_eq!(inflict.statuses, [(MobStat::Dazzle, Amount::Value(1))]); + assert!(inflict.takes(0.42) && !inflict.takes(0.43)); + assert!(MobStat::Dazzle.spares_bosses()); + } + #[test] fn smokescreen_leaves_smoke_and_poison_mist_poison() { assert_eq!(mist_kind(SMOKESCREEN), MistKind::Smoke);