Skip to main content

steel_core/entity/
potion_contents.rs

1//! Vanilla `PotionContents` behavior extension: the methods that need
2//! `LivingEntity`/`World`, which can't live alongside the data in
3//! `steel_registry::data_components::PotionContents`.
4
5use steel_registry::MobEffectInstance as RegistryMobEffectInstance;
6use steel_registry::data_components::PotionContents;
7
8use crate::behavior::MOB_EFFECT_BEHAVIORS;
9use crate::entity::{Entity, LivingEntity, MobEffectInstance as RuntimeMobEffectInstance};
10use crate::world::World;
11
12/// Mirrors vanilla `PotionContents.applyToLivingEntity(user, durationScale)`.
13pub(crate) fn apply_potion_contents(
14    contents: &PotionContents,
15    world: &World,
16    user: &dyn LivingEntity,
17    duration_scale: f32,
18) {
19    // Vanilla passes the drinker itself as both `source` and `owner` when it
20    // is a player (`null` otherwise), attributing instantaneous damage to it.
21    let damage_source_entity = user.as_player().map(Entity::id);
22    for effect in contents.all_effects() {
23        let behavior = MOB_EFFECT_BEHAVIORS.get_behavior(effect.effect());
24        if let Some(instantaneous) = behavior.as_instantaneous() {
25            // Vanilla always passes `scale = 1.0` from this call site; only a
26            // splash/lingering potion (not yet implemented) passes a
27            // distance-based falloff scale, and a `source` distinct from
28            // `owner`.
29            instantaneous.apply_instantaneous(
30                world,
31                user,
32                effect.amplifier(),
33                damage_source_entity,
34                damage_source_entity,
35                1.0,
36            );
37            continue;
38        }
39
40        let scaled_duration = scale_effect_duration(effect.duration(), duration_scale);
41        user.add_mob_effect(to_runtime_instance(&effect, scaled_duration));
42    }
43}
44
45/// Mirrors vanilla `MobEffectInstance.withScaledDuration`: scales `duration`
46/// by `scale`, leaving the infinite-duration sentinel (`-1`) and a zero
47/// duration untouched, and never rounding a finite result below 1 tick.
48fn scale_effect_duration(duration: i32, scale: f32) -> i32 {
49    if duration == -1 || duration == 0 {
50        return duration;
51    }
52    ((duration as f32 * scale).floor() as i32).max(1)
53}
54
55/// Builds the runtime active-effect state for one registry mob-effect
56/// instance, ready to hand to `LivingEntity::add_mob_effect`.
57pub(crate) const fn to_runtime_instance(
58    effect: &RegistryMobEffectInstance,
59    duration: i32,
60) -> RuntimeMobEffectInstance {
61    RuntimeMobEffectInstance::with_duration(effect.effect(), duration, effect.amplifier())
62        .with_ambient(effect.ambient())
63        .with_visible(effect.show_particles())
64        .with_show_icon(effect.show_icon())
65}
66
67#[cfg(test)]
68mod tests {
69    use steel_registry::data_components::PotionContents;
70    use steel_registry::{
71        MobEffectInstance as RegistryMobEffectInstance, init_vanilla_registry, vanilla_mob_effects,
72    };
73    use steel_utils::ChunkPos;
74
75    use super::{apply_potion_contents, scale_effect_duration};
76    use crate::entity::LivingEntity;
77    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
78
79    /// Mirrors vanilla `MobEffectInstance.mapDuration`: the infinite-duration
80    /// sentinel (`-1`) and a zero duration are returned unscaled.
81    #[test]
82    fn scale_effect_duration_leaves_infinite_and_zero_durations_untouched() {
83        assert_eq!(scale_effect_duration(-1, 0.5), -1);
84        assert_eq!(scale_effect_duration(0, 0.5), 0);
85        // Even an extreme scale must not touch these sentinels.
86        assert_eq!(scale_effect_duration(-1, 100.0), -1);
87        assert_eq!(scale_effect_duration(0, 0.0), 0);
88    }
89
90    /// Mirrors vanilla `withScaledDuration`: `Math.max(Mth.floor(duration *
91    /// scale), 1)` — a finite duration is floor-scaled and never rounds
92    /// below 1 tick, even when the scale would floor it to 0.
93    #[test]
94    fn scale_effect_duration_floors_and_clamps_finite_durations() {
95        assert_eq!(scale_effect_duration(100, 0.5), 50);
96        // floor(9 * 0.34) == floor(3.06) == 3, not a naive round to 3.
97        assert_eq!(scale_effect_duration(9, 0.34), 3);
98        // A scale that would floor to 0 is clamped up to the 1-tick floor.
99        assert_eq!(scale_effect_duration(1, 0.1), 1);
100        assert_eq!(scale_effect_duration(100, 1.0), 100);
101    }
102
103    /// Vanilla's `int` shift is masked to the low 5 bits (Java `<<` never
104    /// throws), so an Instant Health/Instant Damage amplifier of 32 or more
105    /// must not panic and must reproduce that masked value rather than the
106    /// naive (and here overflowing) shift amount.
107    #[test]
108    fn instant_health_amplifier_at_shift_width_does_not_panic_and_wraps_like_vanilla() {
109        init_vanilla_registry();
110        let world = fresh_test_world("instant_health_high_amplifier");
111        insert_ready_full_chunk(&world, ChunkPos::new(0, 0));
112        let player = TestPlayerBuilder::new(world.clone(), "Test", 1).build();
113        player.set_health(1.0);
114
115        let contents = PotionContents::new(
116            None,
117            None,
118            vec![RegistryMobEffectInstance::simple(
119                vanilla_mob_effects::INSTANT_HEALTH,
120                1,
121                32,
122            )],
123            None,
124        );
125
126        apply_potion_contents(&contents, &world, player.as_ref(), 1.0);
127
128        // 4 << 32 wraps to 4 << (32 % 32) == 4 << 0 == 4, matching Java.
129        assert_eq!(player.get_health(), 5.0);
130    }
131}