overlord_event_system/behaviors/combat/
effects.rs

1//! Native ports for the `event` category — effect `script`s (run via
2//! `run_event`, returning `Vec<OverlordEvent>`).
3//!
4//! Effect scripts are arbitrary per-effect programs that read `Entity.attributes`
5//! and push events (and, for the combat ones, call `ctx.*` fight primitives).
6//! Like `start_cast_ability` they can consume the authoritative RNG (the game
7//! `Random`).
8//!
9//! Scope (per the effect `run_event` call site): `Entity`, `Fight`, `Random`,
10//! `CurrentTick`, `FightDurationTicks`, and the caller `Event`.
11
12use configs::game_config::GameConfig;
13use essences::combat_origin::CombatEventOrigin;
14use essences::entity::Entity;
15use essences::fight_breakdown::CombatSource;
16use essences::fighting::ActiveFight;
17use event_system::script::random::GameRng;
18
19use crate::behaviors::{BehaviorKind, BehaviorMeta, BehaviorRegistry};
20use crate::event::CustomEventData;
21use crate::event::OverlordEvent;
22use crate::mechanics::content_lookups::ContentLookups;
23use crate::mechanics::fight::{self, NativeSink};
24
25/// Inputs available to an `event` (effect) native fn — the effect `run_event`
26/// scope plus config/lookups the combat primitives need.
27pub struct EventCtx<'a> {
28    pub entity: &'a Entity,
29    pub fight: &'a ActiveFight,
30    /// RNG snapshot (clone at the same state) so combat primitives draw
31    pub rng: &'a GameRng,
32    pub current_tick: u64,
33    pub fight_duration_ticks: u64,
34    /// The caller `Event` (`None` if the effect script doesn't read it).
35    pub caller_event: Option<&'a OverlordEvent>,
36    pub config: &'a GameConfig,
37    pub lookups: &'a ContentLookups,
38}
39
40/// Signature of an `event` (effect) native fn.
41pub type EventFn = fn(&EventCtx) -> anyhow::Result<Vec<OverlordEvent>>;
42
43/// `Entity.attributes[name] == ()` (absent key).
44fn attr(entity: &Entity, name: &str) -> Option<i64> {
45    entity.attributes.0.get(name).copied()
46}
47
48/// Build an `EntityIncrAttribute` event.
49fn incr(entity_id: uuid::Uuid, attribute: &str, delta: i64) -> OverlordEvent {
50    OverlordEvent::EntityIncrAttribute {
51        entity_id,
52        attribute: attribute.to_string(),
53        delta,
54    }
55}
56
57/// Shared "duration decrement" tick used by protection/empower/vulnerability/
58/// weakness: if `attr_name` is present, push `Incr(attr_name, -min(dur, tick))`.
59fn duration_decrement(ctx: &EventCtx, attr_name: &str, tick: i64) -> Vec<OverlordEvent> {
60    let Some(dur) = attr(ctx.entity, attr_name) else {
61        return vec![];
62    };
63    let delta = if dur > tick { -tick } else { -dur };
64    vec![incr(ctx.entity.id, attr_name, delta)]
65}
66
67pub fn protection_duration_decrement(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
68    Ok(duration_decrement(ctx, "effect.protection.duration", 100))
69}
70
71pub fn empower_duration_decrement(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
72    Ok(duration_decrement(ctx, "effect.empower.duration", 100))
73}
74
75/// War Cry's self-buff (BAL-033): `+30%` outgoing damage for `5s`.
76pub fn war_fury_duration_decrement(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
77    Ok(duration_decrement(ctx, "effect.war_fury.duration", 100))
78}
79
80/// Battle Heal's team buff (BAL-033): `+15% ATK` and `+15% Armor` for `6s`.
81pub fn battle_blessing_duration_decrement(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
82    Ok(duration_decrement(
83        ctx,
84        "effect.battle_blessing.duration",
85        100,
86    ))
87}
88
89pub fn vulnerability_duration_decrement(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
90    Ok(duration_decrement(
91        ctx,
92        "effect.vulnerability.duration",
93        1000,
94    ))
95}
96
97pub fn weakness_duration_decrement(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
98    Ok(duration_decrement(ctx, "effect.weakness.duration", 100))
99}
100
101/// Port of the sleep tick: if the entity already woke (`sleep` attr absent),
102/// remove the `wake_up_delay` entirely; otherwise decrement `wake_up_delay` by 1
103/// and, when it reaches 1 or less, clear the `sleep` attr.
104pub fn sleep_wake_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
105    let id = ctx.entity.id;
106    // Effect always applied with `wake_up_delay` present; mirror that.
107    let remaining = attr(ctx.entity, "wake_up_delay").unwrap_or(0);
108    let sleep = attr(ctx.entity, "sleep");
109    let mut events = Vec::new();
110    if sleep.is_none() {
111        events.push(incr(id, "wake_up_delay", -remaining));
112    } else {
113        events.push(incr(id, "wake_up_delay", -1));
114        if remaining <= 1 {
115            events.push(incr(id, "sleep", -sleep.unwrap_or(0)));
116        }
117    }
118    Ok(events)
119}
120
121/// Port of the regeneration tick: heal the entity by its `regeneration_rate`
122/// stat plus the share of MAX HP its `regeneration_percent` stat carries
123/// (BAL-034 — the regen class scales with the pool it is protecting, not with
124/// a number authored for early gear). `get_entity_stat` reads a stat (base +
125/// bonus + mod); `heal_entity` pushes a `Heal` event clamped to missing HP (no
126/// RNG).
127pub fn regeneration_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
128    let mut sink = NativeSink::default();
129    let percent = fight::get_entity_stat(
130        ctx.lookups,
131        ctx.entity,
132        crate::mechanics::balance::REGEN_PERCENT_CODE,
133    ) / 10_000.0;
134    let rate = fight::get_entity_stat(ctx.lookups, ctx.entity, "regeneration_rate")
135        + percent * ctx.entity.max_hp as f64;
136    // Balance v2: bound the per-tick heal to a fraction of max HP so very high
137    // regen (e.g. a regen class's grants) can't out-heal all incoming damage /
138    // instant-full the tank. The displayed-power scalar is bounded separately.
139    let capped = crate::mechanics::balance::cap_per_tick(
140        rate,
141        ctx.entity.max_hp as f64,
142        crate::mechanics::balance::tuning().regen_tick_max_pct,
143    );
144    fight::heal_entity(
145        &mut sink,
146        Some(ctx.entity.id),
147        ctx.entity,
148        capped,
149        CombatSource::Regeneration,
150    )
151    .map_err(|e| anyhow::anyhow!("heal_entity: {e}"))?;
152    Ok(sink.events)
153}
154
155/// Port of the heal-over-time (HoT) tick. The effect stores a rolling 5-slot
156/// schedule: `effect.hot.next` is the slot index to consume this tick, and
157/// `effect.hot.tick.<i>` holds each slot's heal amount. Each tick: if the
158/// current slot is empty (absent), reset the cursor to 0; otherwise advance the
159/// cursor (wrapping 5→1), zero the consumed slot, and heal by its amount.
160/// `set_entity_attr`/`add_entity_attr` push `IncrAttribute`s; `heal_entity` is
161/// RNG-free (no draw), so this needs no RNG snapshot.
162pub fn hot_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
163    let mut sink = NativeSink::default();
164    let next_attr = "effect.hot.next";
165    // `required_attributes` guarantees `effect.hot.next` is present at tick time
166    let next_tick_number = attr(ctx.entity, next_attr).unwrap_or(0);
167    let amount_attr = format!("effect.hot.tick.{next_tick_number}");
168    match attr(ctx.entity, &amount_attr) {
169        None => {
170            fight::set_entity_attr(&mut sink, ctx.entity, next_attr, 0.0)
171                .map_err(|e| anyhow::anyhow!("set_entity_attr: {e}"))?;
172        }
173        Some(next_tick_amount) => {
174            if next_tick_number == 5 {
175                fight::set_entity_attr(&mut sink, ctx.entity, next_attr, 1.0)
176                    .map_err(|e| anyhow::anyhow!("set_entity_attr: {e}"))?;
177            } else {
178                fight::add_entity_attr(&mut sink, ctx.entity, next_attr, 1)
179                    .map_err(|e| anyhow::anyhow!("add_entity_attr: {e}"))?;
180            }
181            fight::set_entity_attr(&mut sink, ctx.entity, &amount_attr, 0.0)
182                .map_err(|e| anyhow::anyhow!("set_entity_attr: {e}"))?;
183            // Balance v2: bound the per-tick heal vs max HP so a mis-tuned HoT
184            // can't out-heal all incoming damage (cf. the regen cap).
185            let capped = crate::mechanics::balance::cap_per_tick(
186                next_tick_amount as f64,
187                ctx.entity.max_hp as f64,
188                crate::mechanics::balance::tuning().hot_tick_max_pct,
189            );
190            // A HoT tick outlives the cast that applied it, so it is credited
191            // to the healed entity itself; the ability it came from is read
192            // back from the schedule's source attributes.
193            fight::heal_entity(
194                &mut sink,
195                Some(ctx.entity.id),
196                ctx.entity,
197                capped,
198                fight::over_time_tick_source(ctx.entity, "hot", CombatSource::Hot),
199            )
200            .map_err(|e| anyhow::anyhow!("heal_entity: {e}"))?;
201        }
202    }
203    Ok(sink.events)
204}
205
206/// Port of the damage-over-time (DoT) tick — the damage sibling of [`hot_tick`].
207/// Same 5-slot rolling schedule (`effect.dot.next` cursor + `effect.dot.tick.<i>`
208/// amounts); on a populated slot it advances the cursor, zeroes the slot, and
209/// deals `damage_entity` with `{dot, no_hit_anim}` custom data. Unlike HoT this
210/// CONSUMES rng (`damage_entity` rolls one `block` `stat_throw`).
211pub fn dot_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
212    let mut sink = NativeSink::default();
213    let next_attr = "effect.dot.next";
214    let next_tick_number = attr(ctx.entity, next_attr).unwrap_or(0);
215    let amount_attr = format!("effect.dot.tick.{next_tick_number}");
216    match attr(ctx.entity, &amount_attr) {
217        None => {
218            fight::set_entity_attr(&mut sink, ctx.entity, next_attr, 0.0)
219                .map_err(|e| anyhow::anyhow!("set_entity_attr: {e}"))?;
220        }
221        Some(next_tick_amount) => {
222            if next_tick_number == 5 {
223                fight::set_entity_attr(&mut sink, ctx.entity, next_attr, 1.0)
224                    .map_err(|e| anyhow::anyhow!("set_entity_attr: {e}"))?;
225            } else {
226                fight::add_entity_attr(&mut sink, ctx.entity, next_attr, 1)
227                    .map_err(|e| anyhow::anyhow!("add_entity_attr: {e}"))?;
228            }
229            let mut custom_data = CustomEventData::default();
230            custom_data.add("dot", 1);
231            custom_data.add("no_hit_anim", 1);
232            fight::set_entity_attr(&mut sink, ctx.entity, &amount_attr, 0.0)
233                .map_err(|e| anyhow::anyhow!("set_entity_attr: {e}"))?;
234            // Balance v2: bound the per-tick DoT vs max HP so a mis-tuned DoT
235            // can't one-shot (cf. the regen/HoT caps). Mitigation still applies
236            // inside `damage_entity`.
237            let capped = crate::mechanics::balance::cap_per_tick(
238                next_tick_amount as f64,
239                ctx.entity.max_hp as f64,
240                crate::mechanics::balance::tuning().dot_tick_max_pct,
241            );
242            // A DoT tick carries no dealer: the effect outlives the cast that
243            // applied it, so it stays ownerless and grants no flip progress.
244            // The ability it came from is still named, from the schedule's
245            // source attributes.
246            fight::damage_entity(
247                &mut sink,
248                ctx.rng,
249                ctx.lookups,
250                None,
251                ctx.entity,
252                capped,
253                custom_data,
254                fight::over_time_tick_source(ctx.entity, "dot", CombatSource::Dot),
255            )
256            .map_err(|e| anyhow::anyhow!("damage_entity: {e}"))?;
257        }
258    }
259    Ok(sink.events)
260}
261
262/// Port of the tutorial damage buff: when THIS entity is the one that took
263/// damage (the effect subscribes to `Damage`, and the script guards on
264/// `Event.entity_id == Entity.id`), scale crit chance and damage-reduction by
265/// fraction of HP lost and push the four attribute deltas. RNG-free.
266pub fn tutorial_buff_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
267    const MAX_CRIT: f64 = 0.3;
268    const MAX_DR: f64 = 0.6;
269
270    let event_entity_id = match ctx.caller_event {
271        Some(OverlordEvent::Damage { entity_id, .. }) => Some(*entity_id),
272        _ => None,
273    };
274    if event_entity_id != Some(ctx.entity.id) {
275        return Ok(vec![]);
276    }
277
278    let lost_hp = 1.0 - (ctx.entity.hp as f64) / (ctx.entity.max_hp as f64);
279    let crit_target = (MAX_CRIT * lost_hp * 10000.0).floor() as i64;
280    let dr_target = (MAX_DR * lost_hp * 10000.0).floor() as i64;
281
282    let buff_crit_chance = attr(ctx.entity, "tutorial_buff.crit_chance").unwrap_or(0);
283    let buff_dr = attr(ctx.entity, "tutorial_buff.dr").unwrap_or(0);
284
285    let crit_delta = crit_target - buff_crit_chance;
286    let dr_delta = dr_target - buff_dr;
287
288    let id = ctx.entity.id;
289    Ok(vec![
290        incr(id, "tutorial_buff.crit_chance", crit_delta),
291        incr(id, "crit_chance", crit_delta),
292        incr(id, "tutorial_buff.dr", dr_delta),
293        incr(id, "received_damage", -dr_delta),
294    ])
295}
296
297/// Port of the Shield + Stun effect tick. Both deployed `.script`s have the
298/// identical body `ctx.tick_entity_effect(Entity, "stun")` (plus a test-only
299/// `Result` array-drain we deliberately don't replicate — it never runs on the
300/// production `EventVec` path).
301///
302/// `tick_entity_effect` is **not registered** on the native `FightCtx` engine
303/// and was never defined anywhere in the repo's history (it was only ever
304/// *called*; the combat overhaul, commit `5071d0524`, rewrote the other callers
305/// — empower/weakness/protection/vulnerability — to explicit duration logic and
306/// left Shield/Stun pointing at the now-undefined primitive). The slot therefore
307/// produces **zero events**: this fn is a deliberate no-op returning an empty
308/// vec.
309pub fn effect_tick_stun(_ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
310    Ok(vec![])
311}
312
313/// Port of the `[TEST] Test effect` tick: bump a `test_effect_ticks` counter by
314/// one. RNG-free; only used by the effect's own unit test.
315pub fn test_effect_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
316    let mut sink = NativeSink::default();
317    fight::add_entity_attr(&mut sink, ctx.entity, "test_effect_ticks", 1)
318        .map_err(|e| anyhow::anyhow!("add_entity_attr: {e}"))?;
319    Ok(sink.events)
320}
321
322/// RNG-free; only used by `test_effects::test_effect_with_interval`.
323pub fn bloodleak_tick(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
324    let Some(bloodleak) = attr(ctx.entity, "bloodleak") else {
325        return Ok(vec![]);
326    };
327    Ok(vec![
328        OverlordEvent::Damage {
329            by_entity_id: None,
330            entity_id: ctx.entity.id,
331            damage: bloodleak.max(0) as u64,
332            damage_data: CustomEventData::default(),
333            origin: CombatEventOrigin::Core,
334            source: CombatSource::Other,
335        },
336        incr(ctx.entity.id, "bloodleak", -5),
337    ])
338}
339
340/// Port of the test "heal when about to die" effect (`3b136901-...`), subscribed
341/// RNG-free; only used by `test_effects::test_effect_with_subscribe`.
342pub fn low_hp_heal_on_damage(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
343    let Some(OverlordEvent::Damage {
344        entity_id, damage, ..
345    }) = ctx.caller_event
346    else {
347        return Ok(vec![]);
348    };
349    if *entity_id != ctx.entity.id {
350        return Ok(vec![]);
351    }
352    if ctx.entity.hp.saturating_sub(*damage) < 5 {
353        return Ok(vec![OverlordEvent::Heal {
354            by_entity_id: Some(ctx.entity.id),
355            entity_id: ctx.entity.id,
356            heal: 100,
357            origin: CombatEventOrigin::Core,
358            source: CombatSource::Other,
359        }]);
360    }
361    Ok(vec![])
362}
363
364/// Port of the test "spawn two enemies on death" effect (`39f135d2-...`),
365/// Each spawn draws a random uuid from the authoritative RNG (in order, like the
366pub fn spawn_two_on_death(ctx: &EventCtx) -> anyhow::Result<Vec<OverlordEvent>> {
367    if !matches!(ctx.caller_event, Some(OverlordEvent::EntityDeath { .. })) {
368        return Ok(vec![]);
369    }
370    let template_id =
371        uuid::Uuid::parse_str("0486f548-5040-4b70-b202-8b35a9880939").expect("valid uuid literal");
372    let base = &ctx.entity.coordinates;
373    let spawn = |dy: i64| OverlordEvent::SpawnEntity {
374        id: uuid::Builder::from_random_bytes(ctx.rng.random_bytes()).into_uuid(),
375        entity_template_id: template_id,
376        position: essences::entity::Coordinates {
377            x: base.x + 1,
378            y: base.y + dy,
379        },
380        entity_team: essences::fighting::EntityTeam::Enemy,
381        has_big_hp_bar: false,
382        entity_attributes: essences::entity::EntityAttributes::default(),
383    };
384    Ok(vec![spawn(1), spawn(2)])
385}
386
387/// Register this category's native fns.
388pub fn register(registry: &mut BehaviorRegistry) {
389    let mut reg = |name: &str, title: &str, desc: &str, f: EventFn| {
390        registry.register_event(
391            BehaviorMeta {
392                name: name.to_string(),
393                category: BehaviorKind::Event,
394                title: title.to_string(),
395                description: desc.to_string(),
396            },
397            f,
398        );
399    };
400    reg(
401        "protection_duration_decrement",
402        "Тик длительности protection",
403        "Уменьшает effect.protection.duration на тик (до 100), не уходя в минус.",
404        protection_duration_decrement,
405    );
406    reg(
407        "empower_duration_decrement",
408        "Тик длительности empower",
409        "Уменьшает effect.empower.duration на тик (до 100), не уходя в минус.",
410        empower_duration_decrement,
411    );
412    reg(
413        "war_fury_duration_decrement",
414        "Тик длительности war_fury",
415        "Уменьшает effect.war_fury.duration на тик (до 100), не уходя в минус.",
416        war_fury_duration_decrement,
417    );
418    reg(
419        "battle_blessing_duration_decrement",
420        "Тик длительности battle_blessing",
421        "Уменьшает effect.battle_blessing.duration на тик (до 100), не уходя в минус.",
422        battle_blessing_duration_decrement,
423    );
424    reg(
425        "vulnerability_duration_decrement",
426        "Тик длительности vulnerability",
427        "Уменьшает effect.vulnerability.duration на тик (до 1000), не уходя в минус.",
428        vulnerability_duration_decrement,
429    );
430    reg(
431        "weakness_duration_decrement",
432        "Тик длительности weakness",
433        "Уменьшает effect.weakness.duration на тик (до 100), не уходя в минус.",
434        weakness_duration_decrement,
435    );
436    reg(
437        "sleep_wake_tick",
438        "Тик пробуждения (sleep)",
439        "Тикает wake_up_delay; при пробуждении снимает delay/sleep.",
440        sleep_wake_tick,
441    );
442    reg(
443        "regeneration_tick",
444        "Тик регенерации",
445        "Лечит сущность на её regeneration_rate (get_entity_stat + heal_entity, без RNG).",
446        regeneration_tick,
447    );
448    reg(
449        "hot_tick",
450        "Тик HoT (лечение со временем)",
451        "Тикает 5-слотовое расписание heal-over-time: лечит на amount текущего слота, сдвигает курсор (без RNG).",
452        hot_tick,
453    );
454    reg(
455        "dot_tick",
456        "Тик DoT (урон со временем)",
457        "Тикает 5-слотовое расписание damage-over-time: наносит урон на amount текущего слота (dot/no_hit_anim), сдвигает курсор.",
458        dot_tick,
459    );
460    reg(
461        "tutorial_buff_tick",
462        "Тик обучающего баффа",
463        "При получении урона этой сущностью масштабирует crit_chance/received_damage по доле потерянного HP.",
464        tutorial_buff_tick,
465    );
466    reg(
467        "test_effect_tick",
468        "Тик тестового эффекта",
469        "Увеличивает test_effect_ticks на 1 (только для unit-теста эффекта).",
470        test_effect_tick,
471    );
472    reg(
473        "bloodleak_tick",
474        "Тик эффекта bloodleak (тест)",
475        "Если есть attr `bloodleak`: наносит урон на его величину и уменьшает \
476         `bloodleak` на 5 (только для unit-теста эффекта).",
477        bloodleak_tick,
478    );
479    reg(
480        "low_hp_heal_on_damage",
481        "Лечение при смертельном уроне (тест)",
482        "Подписан на Damage: если урон по этой сущности опускает hp ниже 5, \
483         лечит на 100 (только для unit-теста эффекта).",
484        low_hp_heal_on_damage,
485    );
486    reg(
487        "spawn_two_on_death",
488        "Спавн двух врагов при смерти (тест)",
489        "Подписан на EntityDeath: спавнит двух врагов 0486f548 рядом с сущностью \
490         (только для unit-теста эффекта).",
491        spawn_two_on_death,
492    );
493    reg(
494        "effect_tick_stun",
495        "Тик Shield/Stun",
496        "No-op: deployed Shield/Stun script вызывает незарегистрированный \
497         tick_entity_effect эффект без поведения не создаёт событий — порт \
498         возвращает пустой результат.",
499        effect_tick_stun,
500    );
501}