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