veloren_common_systems/
buff.rs

1use common::{
2    Damage, DamageSource,
3    combat::{self, DamageContributor},
4    comp::{
5        Alignment, Energy, Group, Health, HealthChange, Inventory, LightEmitter, Mass,
6        ModifierKind, PhysicsState, Player, Pos, Stats,
7        agent::{Sound, SoundKind},
8        aura::{Auras, EnteredAuras},
9        body::{Body, object},
10        buff::{
11            Buff, BuffCategory, BuffChange, BuffData, BuffEffect, BuffKey, BuffKind, BuffSource,
12            Buffs, DestInfo,
13        },
14        fluid_dynamics::{Fluid, LiquidKind},
15        item::MaterialStatManifest,
16    },
17    event::{
18        BuffEvent, ChangeBodyEvent, ComboChangeEvent, CreateSpriteEvent, EmitExt,
19        EnergyChangeEvent, HealthChangeEvent, RemoveLightEmitterEvent, SoundEvent,
20    },
21    event_emitters,
22    outcome::Outcome,
23    resources::{DeltaTime, Secs, Time},
24    terrain::SpriteKind,
25    uid::{IdMaps, Uid},
26};
27use common_base::prof_span;
28use common_ecs::{Job, Origin, ParMode, Phase, System};
29use rand::Rng;
30use rayon::iter::ParallelIterator;
31use specs::{
32    Entities, Entity, LendJoin, ParJoin, Read, ReadExpect, ReadStorage, SystemData, WriteStorage,
33    shred,
34};
35use vek::Vec3;
36
37event_emitters! {
38    struct Events[EventEmitters] {
39        buff: BuffEvent,
40        change_body: ChangeBodyEvent,
41        remove_light: RemoveLightEmitterEvent,
42        health_change: HealthChangeEvent,
43        energy_change: EnergyChangeEvent,
44        combo_change: ComboChangeEvent,
45        sound: SoundEvent,
46        create_sprite: CreateSpriteEvent,
47        outcome: Outcome,
48    }
49}
50
51#[derive(SystemData)]
52pub struct ReadData<'a> {
53    entities: Entities<'a>,
54    dt: Read<'a, DeltaTime>,
55    events: Events<'a>,
56    inventories: ReadStorage<'a, Inventory>,
57    healths: ReadStorage<'a, Health>,
58    energies: ReadStorage<'a, Energy>,
59    physics_states: ReadStorage<'a, PhysicsState>,
60    groups: ReadStorage<'a, Group>,
61    id_maps: Read<'a, IdMaps>,
62    time: Read<'a, Time>,
63    msm: ReadExpect<'a, MaterialStatManifest>,
64    buffs: ReadStorage<'a, Buffs>,
65    auras: ReadStorage<'a, Auras>,
66    entered_auras: ReadStorage<'a, EnteredAuras>,
67    positions: ReadStorage<'a, Pos>,
68    bodies: ReadStorage<'a, Body>,
69    light_emitters: ReadStorage<'a, LightEmitter>,
70    alignments: ReadStorage<'a, Alignment>,
71    players: ReadStorage<'a, Player>,
72    masses: ReadStorage<'a, Mass>,
73}
74
75#[derive(Default)]
76pub struct Sys;
77impl<'a> System<'a> for Sys {
78    type SystemData = (ReadData<'a>, WriteStorage<'a, Stats>);
79
80    const NAME: &'static str = "buff";
81    const ORIGIN: Origin = Origin::Common;
82    const PHASE: Phase = Phase::Create;
83
84    fn run(job: &mut Job<Self>, (read_data, mut stats): Self::SystemData) {
85        let mut emitters = read_data.events.get_emitters();
86        let dt = read_data.dt.0;
87        // Set to false to avoid spamming server
88        stats.set_event_emission(false);
89
90        // Put out underwater campfires. Logically belongs here since this system also
91        // removes burning, but campfires don't have healths/stats/energies/buffs, so
92        // this needs a separate loop.
93        job.cpu_stats.measure(ParMode::Rayon);
94        let to_put_out_campfires = (
95            &read_data.entities,
96            &read_data.bodies,
97            &read_data.physics_states,
98            &read_data.light_emitters, //to improve iteration speed
99        )
100            .par_join()
101            .map_init(
102                || {
103                    prof_span!(guard, "buff campfire deactivate");
104                    guard
105                },
106                |_guard, (entity, body, physics_state, _)| {
107                    if matches!(*body, Body::Object(object::Body::CampfireLit))
108                        && matches!(
109                            physics_state.in_fluid,
110                            Some(Fluid::Liquid {
111                                kind: LiquidKind::Water,
112                                ..
113                            })
114                        )
115                    {
116                        Some(entity)
117                    } else {
118                        None
119                    }
120                },
121            )
122            .fold(Vec::new, |mut to_put_out_campfires, put_out_campfire| {
123                put_out_campfire.map(|put| to_put_out_campfires.push(put));
124                to_put_out_campfires
125            })
126            .reduce(
127                Vec::new,
128                |mut to_put_out_campfires_a, mut to_put_out_campfires_b| {
129                    to_put_out_campfires_a.append(&mut to_put_out_campfires_b);
130                    to_put_out_campfires_a
131                },
132            );
133        job.cpu_stats.measure(ParMode::Single);
134        {
135            prof_span!(_guard, "write deferred campfire deletion");
136            // Assume that to_put_out_campfires is near to zero always, so this access isn't
137            // slower than parallel checking above
138            for e in to_put_out_campfires {
139                {
140                    emitters.emit(ChangeBodyEvent {
141                        entity: e,
142                        new_body: Body::Object(object::Body::Campfire),
143                        permanent_change: None,
144                    });
145                    emitters.emit(RemoveLightEmitterEvent { entity: e });
146                }
147            }
148        }
149
150        let mut rng = rand::thread_rng();
151        let buff_join = (
152            &read_data.entities,
153            &read_data.buffs,
154            &mut stats,
155            &read_data.bodies,
156            &read_data.healths,
157            &read_data.energies,
158            read_data.physics_states.maybe(),
159            read_data.masses.maybe(),
160        )
161            .lend_join();
162        buff_join.for_each(|comps| {
163            let (entity, buff_comp, mut stat, body, health, energy, physics_state, mass) = comps;
164            let dest_info = DestInfo {
165                stats: Some(&stat),
166                mass,
167            };
168            // Apply buffs to entity based off of their current physics_state
169            if let Some(physics_state) = physics_state {
170                // Set nearby entities on fire if burning
171                if let Some((_, burning)) = buff_comp.iter_kind(BuffKind::Burning).next() {
172                    for t_entity in physics_state.touch_entities.keys().filter_map(|te_uid| {
173                        read_data.id_maps.uid_entity(*te_uid).filter(|te| {
174                            combat::permit_pvp(
175                                &read_data.alignments,
176                                &read_data.players,
177                                &read_data.entered_auras,
178                                &read_data.id_maps,
179                                Some(entity),
180                                *te,
181                            )
182                        })
183                    }) {
184                        let duration = burning.data.duration.map(|d| d * 0.9);
185                        if duration.is_none_or(|d| d.0 >= 1.0)
186                            && rng
187                                .gen_bool((dt * burning.data.strength / 5.0).clamp(0.0, 1.0).into())
188                        {
189                            // NOTE: setting source as the burned character is
190                            // problematic for whole array of reasons.
191                            // 1) It would show non-sensical death message.
192                            // 2) It makes NPCs hate the victim of fire, which is rather annoying.
193                            // 3) It makes NPCs hate other NPCs and attempting to attack them, even
194                            //    when they are in the same group.
195                            //
196                            // We could reference original source, but it might
197                            // have some cheesing & griefing potential.
198                            //
199                            // Yes, with this implementation you could put
200                            // yourself on fire, and then harras NPCs but good
201                            // luck with that.
202                            emitters.emit(BuffEvent {
203                                entity: t_entity,
204                                buff_change: BuffChange::Add(Buff::new(
205                                    BuffKind::Burning,
206                                    BuffData::new(burning.data.strength, duration),
207                                    vec![BuffCategory::Natural],
208                                    BuffSource::World,
209                                    *read_data.time,
210                                    DestInfo {
211                                        // Can't mutably access stats, and for burning debuff stats
212                                        // has no effect (for now)
213                                        stats: None,
214                                        mass: read_data.masses.get(t_entity),
215                                    },
216                                    mass,
217                                )),
218                            });
219                        }
220                    }
221                }
222                if matches!(
223                    physics_state.on_ground.and_then(|b| b.get_sprite()),
224                    Some(SpriteKind::EnsnaringVines)
225                ) {
226                    // If on ensnaring vines, apply partial ensnared debuff
227                    emitters.emit(BuffEvent {
228                        entity,
229                        buff_change: BuffChange::Add(Buff::new(
230                            BuffKind::Ensnared,
231                            BuffData::new(0.5, Some(Secs(0.1))),
232                            Vec::new(),
233                            BuffSource::World,
234                            *read_data.time,
235                            dest_info,
236                            None,
237                        )),
238                    });
239                }
240                if matches!(
241                    physics_state.on_ground.and_then(|b| b.get_sprite()),
242                    Some(SpriteKind::EnsnaringWeb)
243                ) {
244                    // If on ensnaring web, apply ensnared debuff
245                    emitters.emit(BuffEvent {
246                        entity,
247                        buff_change: BuffChange::Add(Buff::new(
248                            BuffKind::Ensnared,
249                            BuffData::new(1.0, Some(Secs(1.0))),
250                            Vec::new(),
251                            BuffSource::World,
252                            *read_data.time,
253                            dest_info,
254                            None,
255                        )),
256                    });
257                }
258                if matches!(
259                    physics_state.on_ground.and_then(|b| b.get_sprite()),
260                    Some(SpriteKind::SeaUrchin)
261                ) {
262                    // If touching Sea Urchin apply Bleeding buff
263                    emitters.emit(BuffEvent {
264                        entity,
265                        buff_change: BuffChange::Add(Buff::new(
266                            BuffKind::Bleeding,
267                            BuffData::new(1.0, Some(Secs(6.0))),
268                            Vec::new(),
269                            BuffSource::World,
270                            *read_data.time,
271                            dest_info,
272                            None,
273                        )),
274                    });
275                }
276                if matches!(
277                    physics_state.on_ground.and_then(|b| b.get_sprite()),
278                    Some(SpriteKind::HaniwaTrap)
279                ) && !body.immune_to(BuffKind::Bleeding)
280                {
281                    // TODO: Determine a better place to emit sprite change events
282                    if let Some(pos) = read_data.positions.get(entity) {
283                        // If touching Trap - change sprite and apply Bleeding buff
284                        emitters.emit(CreateSpriteEvent {
285                            pos: Vec3::new(pos.0.x as i32, pos.0.y as i32, pos.0.z as i32 - 1),
286                            sprite: SpriteKind::HaniwaTrapTriggered,
287                            del_timeout: Some((4.0, 1.0)),
288                        });
289                        emitters.emit(SoundEvent {
290                            sound: Sound::new(SoundKind::Trap, pos.0, 12.0, read_data.time.0),
291                        });
292                        emitters.emit(Outcome::Slash { pos: pos.0 });
293
294                        emitters.emit(BuffEvent {
295                            entity,
296                            buff_change: BuffChange::Add(Buff::new(
297                                BuffKind::Bleeding,
298                                BuffData::new(5.0, Some(Secs(3.0))),
299                                Vec::new(),
300                                BuffSource::World,
301                                *read_data.time,
302                                dest_info,
303                                None,
304                            )),
305                        });
306                    }
307                }
308                if matches!(
309                    physics_state.on_ground.and_then(|b| b.get_sprite()),
310                    Some(SpriteKind::IronSpike | SpriteKind::HaniwaTrapTriggered)
311                ) {
312                    // If touching Iron Spike apply Bleeding buff
313                    emitters.emit(BuffEvent {
314                        entity,
315                        buff_change: BuffChange::Add(Buff::new(
316                            BuffKind::Bleeding,
317                            BuffData::new(1.0, Some(Secs(4.0))),
318                            Vec::new(),
319                            BuffSource::World,
320                            *read_data.time,
321                            dest_info,
322                            None,
323                        )),
324                    });
325                }
326                if matches!(
327                    physics_state.on_ground.and_then(|b| b.get_sprite()),
328                    Some(SpriteKind::HotSurface)
329                ) {
330                    // If touching a hot surface apply Burning buff
331                    emitters.emit(BuffEvent {
332                        entity,
333                        buff_change: BuffChange::Add(Buff::new(
334                            BuffKind::Burning,
335                            BuffData::new(10.0, None),
336                            Vec::new(),
337                            BuffSource::World,
338                            *read_data.time,
339                            dest_info,
340                            None,
341                        )),
342                    });
343                }
344                if matches!(
345                    physics_state.on_ground.and_then(|b| b.get_sprite()),
346                    Some(SpriteKind::IceSpike)
347                ) {
348                    // When standing on IceSpike, apply bleeding
349                    emitters.emit(BuffEvent {
350                        entity,
351                        buff_change: BuffChange::Add(Buff::new(
352                            BuffKind::Bleeding,
353                            BuffData::new(15.0, Some(Secs(0.1))),
354                            Vec::new(),
355                            BuffSource::World,
356                            *read_data.time,
357                            dest_info,
358                            None,
359                        )),
360                    });
361                    // When standing on IceSpike also apply Frozen
362                    emitters.emit(BuffEvent {
363                        entity,
364                        buff_change: BuffChange::Add(Buff::new(
365                            BuffKind::Frozen,
366                            BuffData::new(0.2, Some(Secs(3.0))),
367                            Vec::new(),
368                            BuffSource::World,
369                            *read_data.time,
370                            dest_info,
371                            None,
372                        )),
373                    });
374                }
375                if matches!(
376                    physics_state.on_ground.and_then(|b| b.get_sprite()),
377                    Some(SpriteKind::FireBlock)
378                ) {
379                    // If on FireBlock vines, apply burning buff
380                    emitters.emit(BuffEvent {
381                        entity,
382                        buff_change: BuffChange::Add(Buff::new(
383                            BuffKind::Burning,
384                            BuffData::new(20.0, None),
385                            Vec::new(),
386                            BuffSource::World,
387                            *read_data.time,
388                            dest_info,
389                            None,
390                        )),
391                    });
392                }
393                // If on FireBlock vines, apply burning buff
394                if matches!(
395                    physics_state.in_fluid,
396                    Some(Fluid::Liquid {
397                        kind: LiquidKind::Lava,
398                        ..
399                    })
400                ) {
401                    // If in lava fluid, apply burning debuff
402                    emitters.emit(BuffEvent {
403                        entity,
404                        buff_change: BuffChange::Add(Buff::new(
405                            BuffKind::Burning,
406                            BuffData::new(20.0, None),
407                            vec![BuffCategory::Natural],
408                            BuffSource::World,
409                            *read_data.time,
410                            dest_info,
411                            None,
412                        )),
413                    });
414                } else if matches!(
415                    physics_state.in_fluid,
416                    Some(Fluid::Liquid {
417                        kind: LiquidKind::Water,
418                        ..
419                    })
420                ) && buff_comp.kinds[BuffKind::Burning].is_some()
421                {
422                    // If in water fluid and currently burning, remove burning debuffs
423                    emitters.emit(BuffEvent {
424                        entity,
425                        buff_change: BuffChange::RemoveByKind(BuffKind::Burning),
426                    });
427                }
428            }
429
430            let mut expired_buffs = Vec::<BuffKey>::new();
431
432            // Replace buffs from an active aura with a normal buff when out of range of the
433            // aura or link no longer active
434            for (buff_key, buff) in &buff_comp.buffs {
435                let keep = buff.cat_ids.iter().all(|cat| match cat {
436                    BuffCategory::FromActiveAura(source, key) => {
437                        let Some(source_entity) = read_data.id_maps.uid_entity(*source) else {
438                            return false;
439                        };
440
441                        let Some(aura) = read_data
442                            .auras
443                            .get(source_entity)
444                            .and_then(|aura| aura.auras.get(*key))
445                        else {
446                            return false;
447                        };
448
449                        let (Some(pos), Some(aura_pos)) = (
450                            read_data.positions.get(entity),
451                            read_data.positions.get(source_entity),
452                        ) else {
453                            return false;
454                        };
455
456                        pos.0.distance_squared(aura_pos.0) <= aura.radius.powi(2)
457                    },
458                    BuffCategory::FromLink(l) => l.exists(),
459                    _ => true,
460                });
461
462                if !keep {
463                    expired_buffs.push(buff_key);
464                    emitters.emit(BuffEvent {
465                        entity,
466                        buff_change: BuffChange::Add(Buff::new(
467                            buff.kind,
468                            buff.data,
469                            buff.cat_ids
470                                .iter()
471                                .filter(|cat_id| {
472                                    !matches!(
473                                        cat_id,
474                                        BuffCategory::FromActiveAura(..)
475                                            | BuffCategory::FromLink(..)
476                                    )
477                                })
478                                .cloned()
479                                .collect::<Vec<_>>(),
480                            buff.source,
481                            *read_data.time,
482                            dest_info,
483                            None,
484                        )),
485                    });
486                }
487            }
488
489            buff_comp.buffs.iter().for_each(|(buff_key, buff)| {
490                if buff.end_time.is_some_and(|end| end.0 < read_data.time.0) {
491                    expired_buffs.push(buff_key)
492                }
493            });
494
495            let infinite_damage_reduction = (Damage::compute_damage_reduction(
496                None,
497                read_data.inventories.get(entity),
498                Some(&stat),
499                &read_data.msm,
500            ) - 1.0)
501                .abs()
502                < f32::EPSILON;
503            if infinite_damage_reduction {
504                for (key, buff) in buff_comp.buffs.iter() {
505                    if !buff.kind.is_buff() {
506                        expired_buffs.push(key);
507                    }
508                }
509            }
510
511            // Call to reset stats to base values
512            stat.reset_temp_modifiers();
513
514            let mut body_override = None;
515
516            // Iterator over the lists of buffs by kind
517            let mut buff_kinds = buff_comp
518                .kinds
519                .iter()
520                .filter_map(|(kind, keys)| keys.as_ref().map(|keys| (kind, keys.clone())))
521                .collect::<Vec<(BuffKind, (Vec<BuffKey>, Time))>>();
522            buff_kinds.sort_by_key(|(kind, _)| !kind.affects_subsequent_buffs());
523            for (buff_kind, (buff_keys, kind_start_time)) in buff_kinds.into_iter() {
524                let mut active_buff_keys = Vec::new();
525                if infinite_damage_reduction && !buff_kind.is_buff() {
526                    continue;
527                }
528
529                if buff_kind.stacks() {
530                    // Process all the buffs of this kind
531                    active_buff_keys = buff_keys;
532                } else {
533                    // Only process the strongest of this buff kind
534                    active_buff_keys.push(buff_keys[0]);
535                }
536                for buff_key in active_buff_keys.into_iter() {
537                    if let Some(buff) = buff_comp.buffs.get(buff_key) {
538                        // Skip the effect of buffs whose start delay hasn't expired.
539                        if buff.start_time.0 > read_data.time.0 {
540                            continue;
541                        }
542                        // Get buff owner?
543                        let buff_owner = if let BuffSource::Character { by: owner } = buff.source {
544                            Some(owner)
545                        } else {
546                            None
547                        };
548
549                        // Now, execute the buff, based on it's delta
550                        for effect in &buff.effects {
551                            execute_effect(
552                                effect,
553                                buff.kind,
554                                buff.start_time,
555                                kind_start_time,
556                                &read_data,
557                                &mut stat,
558                                body,
559                                &mut body_override,
560                                health,
561                                energy,
562                                entity,
563                                buff_owner,
564                                &mut emitters,
565                                dt,
566                                *read_data.time,
567                                expired_buffs.contains(&buff_key),
568                                buff_comp,
569                            );
570                        }
571                    }
572                }
573            }
574
575            // Update body if needed.
576            let new_body = body_override.unwrap_or(stat.original_body);
577            if new_body != *body {
578                emitters.emit(ChangeBodyEvent {
579                    entity,
580                    new_body,
581                    permanent_change: None,
582                });
583            }
584
585            // Remove buffs that expire
586            if !expired_buffs.is_empty() {
587                emitters.emit(BuffEvent {
588                    entity,
589                    buff_change: BuffChange::RemoveByKey(expired_buffs),
590                });
591            }
592
593            // Remove buffs that don't persist on death
594            if health.is_dead {
595                emitters.emit(BuffEvent {
596                    entity,
597                    buff_change: BuffChange::RemoveByCategory {
598                        all_required: vec![],
599                        any_required: vec![],
600                        none_required: vec![BuffCategory::PersistOnDeath],
601                    },
602                });
603            }
604        });
605        // Turned back to true
606        stats.set_event_emission(true);
607    }
608}
609
610// TODO: Globally disable this clippy lint
611#[expect(clippy::too_many_arguments)]
612fn execute_effect(
613    effect: &BuffEffect,
614    buff_kind: BuffKind,
615    buff_start_time: Time,
616    buff_kind_start_time: Time,
617    read_data: &ReadData,
618    stat: &mut Stats,
619    current_body: &Body,
620    body_override: &mut Option<Body>,
621    health: &Health,
622    energy: &Energy,
623    entity: Entity,
624    buff_owner: Option<Uid>,
625    server_emitter: &mut (
626             impl EmitExt<HealthChangeEvent>
627             + EmitExt<EnergyChangeEvent>
628             + EmitExt<ComboChangeEvent>
629             + EmitExt<BuffEvent>
630         ),
631    dt: f32,
632    time: Time,
633    buff_will_expire: bool,
634    buffs_comp: &Buffs,
635) {
636    let num_ticks = |tick_dur: Secs| {
637        let time_passed = time.0 - buff_start_time.0;
638        let dt = dt as f64;
639        // Number of ticks has 3 parts
640        //
641        // First part checks if delta time was larger than the tick duration, if it was
642        // determines number of ticks in that time
643        //
644        // Second part checks if delta time has just passed the threshold for a tick
645        // ending/starting (and accounts for if that delta time was longer than the tick
646        // duration)
647        // 0.000001 is to account for floating imprecision so this is not applied on the
648        // first tick
649        //
650        // Third part returns the fraction of the current time passed since the last
651        // time a tick duration would have happened, this is ignored (by flooring) when
652        // the buff is not ending, but is used if the buff is ending this tick
653        let curr_tick = (time_passed / tick_dur.0).floor();
654        let prev_tick = ((time_passed - dt).max(0.0) / tick_dur.0).floor();
655        let whole_ticks = curr_tick - prev_tick;
656
657        if buff_will_expire {
658            // If the buff is ending, include the fraction of progress towards the next
659            // tick.
660            let fractional_tick = (time_passed % tick_dur.0) / tick_dur.0;
661            Some((whole_ticks + fractional_tick) as f32)
662        } else if whole_ticks >= 1.0 {
663            Some(whole_ticks as f32)
664        } else {
665            None
666        }
667    };
668    match effect {
669        BuffEffect::HealthChangeOverTime {
670            rate,
671            kind,
672            instance,
673            tick_dur,
674        } => {
675            if let Some(num_ticks) = num_ticks(*tick_dur) {
676                let amount = *rate * num_ticks * tick_dur.0 as f32;
677
678                let (cause, by) = if amount != 0.0 {
679                    (Some(DamageSource::Buff(buff_kind)), buff_owner)
680                } else {
681                    (None, None)
682                };
683                let amount = match *kind {
684                    ModifierKind::Additive => amount,
685                    ModifierKind::Multiplicative => health.maximum() * amount,
686                };
687                let damage_contributor = by.and_then(|uid| {
688                    read_data.id_maps.uid_entity(uid).map(|entity| {
689                        DamageContributor::new(uid, read_data.groups.get(entity).cloned())
690                    })
691                });
692                server_emitter.emit(HealthChangeEvent {
693                    entity,
694                    change: HealthChange {
695                        amount,
696                        by: damage_contributor,
697                        cause,
698                        time: *read_data.time,
699                        precise: false,
700                        instance: *instance,
701                    },
702                });
703            };
704        },
705        BuffEffect::EnergyChangeOverTime {
706            rate,
707            kind,
708            tick_dur,
709            reset_rate_on_tick,
710        } => {
711            if let Some(num_ticks) = num_ticks(*tick_dur) {
712                let amount = *rate * num_ticks * tick_dur.0 as f32;
713
714                let amount = match *kind {
715                    ModifierKind::Additive => amount,
716                    ModifierKind::Multiplicative => energy.maximum() * amount,
717                };
718                server_emitter.emit(EnergyChangeEvent {
719                    entity,
720                    change: amount,
721                    reset_rate: *reset_rate_on_tick,
722                });
723            };
724        },
725        BuffEffect::ComboChangeOverTime { rate, tick_dur } => {
726            if let Some(num_ticks) = num_ticks(*tick_dur) {
727                let amount = (*rate * num_ticks * tick_dur.0 as f32) as i32;
728
729                server_emitter.emit(ComboChangeEvent {
730                    entity,
731                    change: amount,
732                });
733            };
734        },
735        BuffEffect::MaxHealthModifier { value, kind } => match kind {
736            ModifierKind::Additive => {
737                stat.max_health_modifiers.add_mod += *value;
738            },
739            ModifierKind::Multiplicative => {
740                stat.max_health_modifiers.mult_mod *= *value;
741            },
742        },
743        BuffEffect::MaxEnergyModifier { value, kind } => match kind {
744            ModifierKind::Additive => {
745                stat.max_energy_modifiers.add_mod += *value;
746            },
747            ModifierKind::Multiplicative => {
748                stat.max_energy_modifiers.mult_mod *= *value;
749            },
750        },
751        BuffEffect::DamageReduction(dr) => {
752            if *dr > 0.0 {
753                stat.damage_reduction.pos_mod = stat.damage_reduction.pos_mod.max(*dr);
754            } else {
755                stat.damage_reduction.neg_mod += dr;
756            }
757        },
758        BuffEffect::MaxHealthChangeOverTime {
759            rate,
760            kind,
761            target_fraction,
762        } => {
763            let potential_amount = (time.0 - buff_kind_start_time.0) as f32 * rate;
764
765            // Percentage change that should be applied to max_health
766            let potential_fraction = 1.0
767                + match kind {
768                    ModifierKind::Additive => {
769                        // `rate * dt` is amount of health, dividing by base max
770                        // creates fraction
771                        potential_amount / health.base_max()
772                    },
773                    ModifierKind::Multiplicative => {
774                        // `rate * dt` is the fraction
775                        potential_amount
776                    },
777                };
778
779            // Potential progress towards target fraction, if
780            // target_fraction ~ 1.0 then set progress to 1.0 to avoid
781            // divide by zero
782            let progress = if (1.0 - *target_fraction).abs() > f32::EPSILON {
783                (1.0 - potential_fraction) / (1.0 - *target_fraction)
784            } else {
785                1.0
786            };
787
788            // Change achieved_fraction depending on what other buffs have
789            // occurred
790            let achieved_fraction = if progress > 1.0 {
791                // If potential fraction already beyond target fraction,
792                // simply multiply max_health_modifier by the target
793                // fraction, and set achieved fraction to target_fraction
794                *target_fraction
795            } else {
796                // Else have not achieved target yet, use potential_fraction
797                potential_fraction
798            };
799
800            // Apply achieved_fraction to max_health_modifier
801            stat.max_health_modifiers.mult_mod *= achieved_fraction;
802        },
803        BuffEffect::MovementSpeed(speed) => {
804            stat.move_speed_modifier *= *speed;
805        },
806        BuffEffect::AttackSpeed(speed) => {
807            stat.attack_speed_modifier *= *speed;
808        },
809        BuffEffect::RecoverySpeed(speed) => {
810            stat.recovery_speed_modifier *= *speed;
811        },
812        BuffEffect::GroundFriction(gf) => {
813            stat.friction_modifier *= *gf;
814        },
815        BuffEffect::PoiseReduction(pr) => {
816            if *pr > 0.0 {
817                stat.poise_reduction.pos_mod = stat.poise_reduction.pos_mod.max(*pr);
818            } else {
819                stat.poise_reduction.neg_mod += pr;
820            }
821        },
822        BuffEffect::PoiseDamageFromLostHealth(strength) => {
823            stat.poise_damage_modifier *= 1.0 + (1.0 - health.fraction()) * *strength;
824        },
825        BuffEffect::AttackDamage(dam) => {
826            stat.attack_damage_modifier *= *dam;
827        },
828        BuffEffect::PrecisionOverride(val) => {
829            // Use lower of precision multiplier overrides
830            stat.precision_multiplier_override = stat
831                .precision_multiplier_override
832                .map(|mult| mult.min(*val))
833                .or(Some(*val));
834        },
835        BuffEffect::PrecisionVulnerabilityOverride(val) => {
836            // Use higher of precision multiplier overrides
837            stat.precision_vulnerability_multiplier_override = stat
838                .precision_vulnerability_multiplier_override
839                .map(|mult| mult.max(*val))
840                .or(Some(*val));
841        },
842        BuffEffect::BodyChange(b) => {
843            // For when an entity is under the effects of multiple de/buffs that change the
844            // body, to avoid flickering between many bodies only change the body if the
845            // override body is not equal to the current body. (If the buff that caused the
846            // current body is still active, body override will eventually pick up on it,
847            // otherwise this will end up with a new body, though random depending on
848            // iteration order)
849            if Some(current_body) != body_override.as_ref() {
850                *body_override = Some(*b)
851            }
852        },
853        BuffEffect::BuffImmunity(buff_kind) => {
854            if buffs_comp.contains(*buff_kind) {
855                server_emitter.emit(BuffEvent {
856                    entity,
857                    buff_change: BuffChange::RemoveByKind(*buff_kind),
858                });
859            }
860        },
861        BuffEffect::SwimSpeed(speed) => {
862            stat.swim_speed_modifier *= speed;
863        },
864        BuffEffect::AttackEffect(effect) => stat.effects_on_attack.push(effect.clone()),
865        BuffEffect::AttackPoise(p) => {
866            stat.poise_damage_modifier *= p;
867        },
868        BuffEffect::MitigationsPenetration(mp) => {
869            stat.mitigations_penetration =
870                1.0 - ((1.0 - stat.mitigations_penetration) * (1.0 - *mp));
871        },
872        BuffEffect::EnergyReward(er) => {
873            stat.energy_reward_modifier *= er;
874        },
875        BuffEffect::DamagedEffect(effect) => stat.effects_on_damaged.push(effect.clone()),
876        BuffEffect::DeathEffect(effect) => stat.effects_on_death.push(effect.clone()),
877        BuffEffect::DisableAuxiliaryAbilities => stat.disable_auxiliary_abilities = true,
878        BuffEffect::CrowdControlResistance(ccr) => {
879            stat.crowd_control_resistance += ccr;
880        },
881        BuffEffect::ItemEffectReduction(ier) => {
882            stat.item_effect_reduction *= 1.0 - ier;
883        },
884    };
885}