overlord_event_system/mechanics/
fight.rs

1//! Native Rust fight logic.
2//!
3//! These are the pure-Rust fight primitives (`attack`, `try_cast`,
4//! `spawn_wave`, ...) used by the native combat ports in
5//! [`crate::behaviors`]. Results are emitted through the [`FightSink`]
6//! abstraction and effect reactions through the [`EffectCb`] abstraction; the
7//! shipped reactions live in [`crate::mechanics::effect_cb::OverlordEffectCb`].
8//!
9
10use std::sync::Arc;
11
12use configs::game_config::GameConfig;
13use essences::abilities::Ability;
14use essences::entity::{Coordinates, Entity, EntityAttributes};
15use essences::fighting::{ActiveFight, EntityTeam};
16use event_system::script::random::GameRng;
17use uuid::Uuid;
18
19use crate::event::CustomEventData;
20use crate::event::*;
21use crate::game_config_helpers::GameConfigLookup;
22use crate::mechanics::balance;
23use crate::mechanics::content_lookups::{ContentLookups, EffectTpl};
24use crate::state::OverlordState;
25
26const BATTLEFIELD_HEIGHT: i64 = 3;
27const DOT_EFFECT_ID: &str = "019589cb-7adf-7466-8c46-92ac45032880";
28const HOT_EFFECT_ID: &str = "01958c0a-329e-744d-a5eb-0369e3f4c024";
29const REGEN_EFFECT_ID: &str = "01978d0c-5a8a-7b50-aaa8-e4cd7782f8bc";
30/// Effect applied to the player below `LOW_CHAPTER_EFFECT_MAX_CHAPTER`.
31const LOW_CHAPTER_EFFECT_ID: &str = "019e27f4-f16b-7881-87cf-bc55605cb80b";
32/// Inclusive chapter ceiling for applying `LOW_CHAPTER_EFFECT_ID`.
33/// Matches `init_fight` in fight.yaml (raised from 10 to 16 in OVT-2405).
34const LOW_CHAPTER_EFFECT_MAX_CHAPTER: i64 = 16;
35const SLEEP_EFFECT_ID: &str = "0198f78c-70d9-7962-b34d-4222f5afa6a4";
36const DUNGEON_TALENT_ID: &str = "019d1c47-5a66-76e5-bb18-6d115e5c6942";
37const BOSS_TALENT_ID: &str = "019d1c47-8cc2-7136-b95b-68e15d7efd0c";
38const COUNTERATTACK_PROJECTILE: &str = "019aeeed-5bcc-7dcc-a74f-589031a6b8f2";
39
40// ---------------------------------------------------------------------------
41// Output sink — abstracts how a fight method emits its results.
42// ---------------------------------------------------------------------------
43
44/// Where a fight method emits its results.
45pub trait FightSink {
46    fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error>;
47    fn push_attack(&mut self, delay: u64, duration: u64, target: Uuid)
48    -> Result<(), anyhow::Error>;
49    fn push_run(&mut self, coords: Coordinates, duration: u64) -> Result<(), anyhow::Error>;
50}
51
52/// Sink that collects fight results as typed values for native callers. Mirrors
53/// `start_behavior`s fill `casts`.
54#[derive(Default)]
55pub struct NativeSink {
56    pub events: Vec<OverlordEvent>,
57    pub casts: Vec<crate::behaviors::combat::start_cast::StartCastAbilityScriptResult>,
58}
59
60impl FightSink for NativeSink {
61    fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error> {
62        self.events.push(event);
63        Ok(())
64    }
65
66    fn push_attack(
67        &mut self,
68        delay: u64,
69        duration: u64,
70        target: Uuid,
71    ) -> Result<(), anyhow::Error> {
72        self.casts.push(
73            crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
74                delay_ticks: Some(delay),
75                animation_duration_ticks: Some(duration),
76                target_entity_id: Some(target),
77                ..Default::default()
78            },
79        );
80        Ok(())
81    }
82
83    fn push_run(&mut self, coords: Coordinates, duration: u64) -> Result<(), anyhow::Error> {
84        self.casts.push(
85            crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
86                coordinates: Some(coords),
87                run_duration_ticks: Some(duration),
88                ..Default::default()
89            },
90        );
91        Ok(())
92    }
93}
94
95/// Generic event push: converts any `EventStruct<OverlordEvent>` to the enum and
96/// emits it through the sink.
97fn push_event<ES>(sink: &mut dyn FightSink, event: ES) -> Result<(), anyhow::Error>
98where
99    ES: event_system::event::EventStruct<OverlordEvent> + 'static,
100{
101    sink.push_event(event.to_enum())
102}
103
104// ---------------------------------------------------------------------------
105// Effect callbacks.
106//
107// Effect reactions (`on_apply` / `on_change`) are abstracted behind this trait
108// so the native fns call `effects.on_apply(...)` without knowing the concrete
109// dispatcher. The shipped reactions are native, keyed by effect `code`, in
110// [`crate::mechanics::effect_cb::OverlordEffectCb`]. Callers that don't
111// want reactions pass [`NoopEffectCb`].
112// ---------------------------------------------------------------------------
113
114/// Invokes an effect's `on_apply` / `on_change` reaction.
115pub trait EffectCb {
116    /// Called after an effect's duration is (re)applied, when the effect has an
117    /// `on_apply` reaction.
118    fn on_apply(
119        &mut self,
120        sink: &mut dyn FightSink,
121        effect: &EffectTpl,
122        target: &Entity,
123    ) -> Result<(), anyhow::Error>;
124
125    /// Called when an effect's stack count changes, when the effect has an
126    /// `on_change` reaction.
127    fn on_change(
128        &mut self,
129        sink: &mut dyn FightSink,
130        effect: &EffectTpl,
131        target: &Entity,
132        new_stacks: i64,
133        old_stacks: i64,
134    ) -> Result<(), anyhow::Error>;
135}
136
137/// Effect callback that does nothing — for callers that don't want effect
138/// reactions dispatched.
139pub struct NoopEffectCb;
140
141impl EffectCb for NoopEffectCb {
142    fn on_apply(
143        &mut self,
144        _sink: &mut dyn FightSink,
145        _effect: &EffectTpl,
146        _target: &Entity,
147    ) -> Result<(), anyhow::Error> {
148        Ok(())
149    }
150
151    fn on_change(
152        &mut self,
153        _sink: &mut dyn FightSink,
154        _effect: &EffectTpl,
155        _target: &Entity,
156        _new_stacks: i64,
157        _old_stacks: i64,
158    ) -> Result<(), anyhow::Error> {
159        Ok(())
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Attribute helpers
165// ---------------------------------------------------------------------------
166
167fn attr_get(entity: &Entity, attr_code: &str) -> f64 {
168    entity.attributes.0.get(attr_code).copied().unwrap_or(0) as f64
169}
170
171fn attr_present(entity: &Entity, attr_code: &str) -> bool {
172    entity.attributes.0.contains_key(attr_code)
173}
174
175/// Push an `EntityIncrAttribute` event. Rounds f64 deltas to the nearest int.
176fn incr_attr(
177    sink: &mut dyn FightSink,
178    entity_id: Uuid,
179    attr: &str,
180    delta: i64,
181) -> Result<(), anyhow::Error> {
182    push_event(
183        sink,
184        OverlordEventEntityIncrAttribute {
185            entity_id,
186            attribute: attr.to_string(),
187            delta,
188        },
189    )
190}
191
192/// `ctx.get_entity_stat(entity, "armor")` — applies attribute base value (from
193/// content_raw), additive `bonus`, and multiplicative `mod` (`+10000 == +1.0`).
194pub fn get_entity_stat(lookups: &ContentLookups, entity: &Entity, stat_code: &str) -> f64 {
195    let mut stat = attr_get(entity, stat_code);
196    if let Some(attr_id) = lookups.attribute_by_code.get(stat_code)
197        && let Some(base) = lookups.attribute_base_value.get(attr_id)
198    {
199        stat += base;
200    }
201    let bonus = attr_get(entity, &format!("{stat_code}.bonus"));
202    // Balance v2: floor the `.mod` multiplier so a stacking debuff (weakness on
203    // attack, protection on received_damage, …) can't drive a stat to ≤0
204    // (zero-damage / unkillable). Only bites at mod ≤ −9500; buffs unaffected.
205    let mod_v = (attr_get(entity, &format!("{stat_code}.mod")) / 10000.0 + 1.0)
206        .max(balance::MIN_STAT_MOD_MULT);
207    (stat + bonus) * mod_v
208}
209
210/// True iff `stat_code` resolves to a promoted base value in
211/// `attribute_base_value`. Lets combat tell a genuine "base missing" content
212/// regression (the whole `attribute_base_value` lookup empty — see
213/// `content_raw_extract`) apart from a stat that merely composed low via mods.
214fn has_attribute_base(lookups: &ContentLookups, stat_code: &str) -> bool {
215    lookups
216        .attribute_by_code
217        .get(stat_code)
218        .is_some_and(|id| lookups.attribute_base_value.contains_key(id))
219}
220
221pub fn stat_throw(
222    random: &GameRng,
223    lookups: &ContentLookups,
224    entity: &Entity,
225    stat_code: &str,
226    bonus: f64,
227) -> bool {
228    let stat_value = get_entity_stat(lookups, entity, stat_code) + bonus;
229    if stat_value > 0.0 {
230        let success = stat_value / 10000.0;
231        return random.random_f64() < success;
232    }
233    false
234}
235
236// ---------------------------------------------------------------------------
237// Entity geometry / target selection
238// ---------------------------------------------------------------------------
239
240/// True when a mover of `width` can place its front on `c` without overlapping
241/// any other entity's current cell or reserved `move_target`. The body extends
242/// behind the front (left for +x movers, right for -x). `mover_id` is skipped.
243fn cell_exists_and_free(
244    c: &Coordinates,
245    entities: &[Entity],
246    mover_id: Uuid,
247    width: i64,
248    direction: i64,
249) -> bool {
250    if c.y < 0 || c.y >= BATTLEFIELD_HEIGHT {
251        return false;
252    }
253    let w = width.max(1);
254    // The cells the mover's body covers with its front at `c`, on row `c.y`.
255    let (lo, hi) = if direction >= 0 {
256        (c.x - (w - 1), c.x)
257    } else {
258        (c.x, c.x + (w - 1))
259    };
260    let covers = |p: &Coordinates| p.y == c.y && p.x >= lo && p.x <= hi;
261    for entity in entities {
262        if entity.id == mover_id {
263            continue;
264        }
265        if covers(&entity.coordinates) {
266            return false;
267        }
268        if let Some(target) = &entity.move_target
269            && covers(target)
270        {
271            return false;
272        }
273    }
274    true
275}
276
277fn entity_get_tpl_width(config: &GameConfig, entity: &Entity) -> i64 {
278    if let Some(tpl_id) = entity.entity_template_id
279        && let Some(tpl) = config.entity_template(tpl_id)
280    {
281        return tpl.width as i64;
282    }
283    1
284}
285
286fn entity_get_tpl_cast_time(config: &GameConfig, entity: &Entity) -> i64 {
287    if let Some(tpl_id) = entity.entity_template_id
288        && let Some(tpl) = config.entity_template(tpl_id)
289    {
290        return tpl.cast_time as i64;
291    }
292    if let Some(class_id) = entity.class_id
293        && let Some(class) = config.class(class_id)
294    {
295        return class.cast_time as i64;
296    }
297    0
298}
299
300fn find_advance_coordinates(
301    entity: &Entity,
302    entities: &[Entity],
303    config: &GameConfig,
304) -> Option<Coordinates> {
305    let pos = &entity.coordinates;
306    let width = entity_get_tpl_width(config, entity);
307    let direction: i64 = if entity.team == EntityTeam::Ally {
308        1
309    } else {
310        -1
311    };
312    let x = pos.x + direction;
313
314    let cell_up = Coordinates { x, y: pos.y + 1 };
315    let cell_forward = Coordinates { x, y: pos.y };
316    let cell_down = Coordinates { x, y: pos.y - 1 };
317
318    let mid = (BATTLEFIELD_HEIGHT - 1) / 2;
319    let order: [&Coordinates; 3] = if pos.y > mid {
320        [&cell_up, &cell_forward, &cell_down]
321    } else if pos.y < mid {
322        [&cell_down, &cell_forward, &cell_up]
323    } else {
324        [&cell_forward, &cell_up, &cell_down]
325    };
326
327    for c in order {
328        if cell_exists_and_free(c, entities, entity.id, width, direction) {
329            return Some(c.clone());
330        }
331    }
332    None
333}
334
335fn get_distance_to(a: &Entity, b: &Entity) -> i64 {
336    (b.coordinates.x - a.coordinates.x).abs()
337}
338
339fn get_target_with_lowest_hp(targets: &[Entity]) -> Option<&Entity> {
340    targets.iter().min_by_key(|e| e.hp)
341}
342
343fn get_closest_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
344    targets.iter().min_by_key(|t| {
345        (t.coordinates.x - caster.coordinates.x).abs()
346            + (t.coordinates.y - caster.coordinates.y).abs()
347    })
348}
349
350fn get_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
351    if caster.entity_template_id.is_some() {
352        get_closest_target(caster, targets)
353    } else {
354        get_target_with_lowest_hp(targets)
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Top-level fight methods
360// ---------------------------------------------------------------------------
361
362/// Native: `ctx.add_entity_attr(entity, attr, value)` — push an `IncrAttribute`
363pub fn add_entity_attr(
364    sink: &mut dyn FightSink,
365    entity: &Entity,
366    attr_code: &str,
367    value: i64,
368) -> Result<(), anyhow::Error> {
369    incr_attr(sink, entity.id, attr_code, value)
370}
371
372/// Native: `ctx.add_entity_stat_mod(entity, stat, value)` — push an
373/// `IncrAttribute` on `"{stat}.mod"` by `value`. Mirrors the engine's
374/// `add_entity_stat_mod` (which lowers to `add_entity_attr(entity, "{stat}.mod",
375/// value)`); used by the native effect-callback dispatcher to replicate the
376pub fn add_entity_stat_mod(
377    sink: &mut dyn FightSink,
378    entity: &Entity,
379    stat: &str,
380    value: i64,
381) -> Result<(), anyhow::Error> {
382    add_entity_attr(sink, entity, &format!("{stat}.mod"), value)
383}
384
385/// Native: `ctx.set_entity_attr(entity, attr, value)` — push an `IncrAttribute`
386/// that moves the attr TO `value` (delta = `round(value - current)`). Emitted
387pub fn set_entity_attr(
388    sink: &mut dyn FightSink,
389    entity: &Entity,
390    attr_code: &str,
391    value: f64,
392) -> Result<(), anyhow::Error> {
393    let current = attr_get(entity, attr_code);
394    incr_attr(sink, entity.id, attr_code, (value - current).round() as i64)
395}
396
397/// Native: returns whether `target` is touched (i.e. did NOT evade). Balance
398/// v2: dodge probability follows the DR curve `ev/(ev+K_DODGE)` (asymptote
399/// < 100%, no 100%-dodge cliff) instead of the old linear `ev/10000`. Consumes
400/// exactly one `rng` draw iff the target has positive evasion rating — matching
401/// the old `stat_throw`'s "draw only when stat > 0" so draw-count is preserved.
402pub fn touch_enemy(rng: &GameRng, lookups: &ContentLookups, target: &Entity) -> bool {
403    let evasion = get_entity_stat(lookups, target, "evasion");
404    if evasion <= 0.0 {
405        return true;
406    }
407    let dodge = evasion / (evasion + balance::tuning().k_dodge);
408    // touched ⇔ not dodged ⇔ roll ≥ dodge (one draw, matching the old throw).
409    rng.random_f64() >= dodge
410}
411
412/// Native: emit a `screen_shake` visual event.
413pub fn screen_shake(sink: &mut dyn FightSink, power: i64) -> Result<(), anyhow::Error> {
414    let mut data = CustomEventData::default();
415    data.add("screen_shake_power", power);
416    push_event(
417        sink,
418        OverlordEventFightVisualEvent {
419            effect_type: "screen_shake".to_string(),
420            effect_data: data,
421        },
422    )
423}
424
425/// Native: heal `entity`, capping at its missing HP. Returns the applied heal
426/// (`None` if no heal was applied).
427pub fn heal_entity(
428    sink: &mut dyn FightSink,
429    entity: &Entity,
430    amount: f64,
431) -> Result<Option<i64>, anyhow::Error> {
432    let max_heal = entity.max_hp as i64 - entity.hp as i64;
433    let heal = max_heal.min(amount.floor() as i64);
434    if heal <= 0 {
435        return Ok(None);
436    }
437    push_event(
438        sink,
439        OverlordEventHeal {
440            entity_id: entity.id,
441            heal: heal as u64,
442        },
443    )?;
444    Ok(Some(heal))
445}
446
447/// Native: apply `raw_dmg` to `entity` (armor, shield, block roll, received
448/// damage multiplier). Returns the floored post-mitigation damage value; `None`
449/// only for the no-damage early-outs (`godmode` / a missing `received_damage`
450/// base value — a content-extraction regression), which short-circuit before
451/// the block roll. When a shield fully absorbs the
452/// hit the function still returns `Some(floored_damage)` (and emits the shield
453/// decrement) — this value drives `attack`'s return / lifesteal, so it
454/// must not be `None` or the caller would discard the shield event.
455/// Consumes one `block` `stat_throw` from `rng` when it is reached.
456pub fn damage_entity(
457    sink: &mut dyn FightSink,
458    rng: &GameRng,
459    lookups: &ContentLookups,
460    entity: &Entity,
461    raw_dmg: f64,
462    mut custom_data: CustomEventData,
463) -> Result<Option<i64>, anyhow::Error> {
464    if attr_present(entity, "godmode") {
465        return Ok(None);
466    }
467    let armor = get_entity_stat(lookups, entity, "armor");
468    let shield = attr_get(entity, "shield");
469    // `received_damage` is a ×10000 multiplier whose base value (10000 == 100%
470    // taken) lives in `attribute_base_value`. If that lookup is *entirely absent*
471    // the content pipeline failed to promote it (see `content_raw_extract`) —
472    // keep the loud legacy behaviour (cancel all damage → fights visibly stall)
473    // so a content/extraction regression still surfaces, instead of silently
474    // degrading to the floor below (~1% damage, a 100×-slower near-stalemate).
475    if !has_attribute_base(lookups, "received_damage") {
476        return Ok(None);
477    }
478    // Balance v2: with the base present, floor received-damage at a small
479    // positive instead of treating `≤0` as full invuln — a negative
480    // `received_damage.mod` (e.g. stacked `protection`, −50%/stack) would
481    // otherwise cancel ALL damage forever and make the entity unkillable.
482    // `godmode` above remains the only true invuln.
483    let mut received_damage_k =
484        get_entity_stat(lookups, entity, "received_damage").max(balance::MIN_RECEIVED_DAMAGE_K);
485
486    if stat_throw(rng, lookups, entity, "block", 0.0) {
487        received_damage_k *= 0.5;
488        custom_data.add("block", 1);
489    }
490
491    let dmg_k = balance::armor_k(armor) * received_damage_k / 10000.0;
492    let dmg = raw_dmg * dmg_k;
493    let res = dmg.floor() as i64;
494    if shield == 0.0 {
495        push_event(
496            sink,
497            OverlordEventDamage {
498                entity_id: entity.id,
499                damage: res.max(0) as u64,
500                damage_data: custom_data,
501            },
502        )?;
503    } else if dmg > shield {
504        incr_attr(sink, entity.id, "shield", -(shield as i64))?;
505        let dmg_hp = (dmg - shield).floor() as i64;
506        custom_data.add("shield_damage", shield as i64);
507        push_event(
508            sink,
509            OverlordEventDamage {
510                entity_id: entity.id,
511                damage: dmg_hp.max(0) as u64,
512                damage_data: custom_data,
513            },
514        )?;
515    } else {
516        incr_attr(sink, entity.id, "shield", -(dmg.round() as i64))?;
517    }
518    Ok(Some(res))
519}
520
521/// Typed form of the `attack` params.
522#[derive(Clone, Debug, Default)]
523pub struct AttackParams {
524    pub no_counterattack: bool,
525    pub crit_chance_bonus: f64,
526    /// Forced crit value; `None` => roll `crit_chance`.
527    pub is_crit: Option<bool>,
528    pub power: Option<f64>,
529    pub dot_power: Option<f64>,
530}
531
532impl AttackParams {
533    /// The default `attack(caster, target)` overload uses `power = 1.0`.
534    pub fn default_power() -> Self {
535        Self {
536            power: Some(1.0),
537            ..Default::default()
538        }
539    }
540}
541
542/// Native: perform an attack from `caster` on `target`. Returns the floored
543/// damage dealt (`None` => the no-damage paths: evasion, or no `power` given).
544/// RNG is consumed in the exact original order: evasion, counterattack_chance,
545/// deceit (+ `randint(0,2)` if it lands), crit_chance, then `damage_entity`'s
546/// block roll.
547#[allow(clippy::too_many_arguments)]
548pub fn attack(
549    sink: &mut dyn FightSink,
550    rng: &GameRng,
551    lookups: &ContentLookups,
552    effects: &mut dyn EffectCb,
553    player_id: Uuid,
554    caster: &Entity,
555    target: &Entity,
556    params: &AttackParams,
557) -> Result<Option<i64>, anyhow::Error> {
558    if !touch_enemy(rng, lookups, target) {
559        push_event(
560            sink,
561            OverlordEventEvasion {
562                entity_id: target.id,
563            },
564        )?;
565        return Ok(None);
566    }
567    if !params.no_counterattack && stat_throw(rng, lookups, target, "counterattack_chance", 0.0) {
568        push_event(
569            sink,
570            OverlordEventCounterAttack {
571                by_entity_id: target.id,
572                to_entity_id: caster.id,
573                duration_ticks: 200,
574            },
575        )?;
576        push_event(
577            sink,
578            OverlordEventStartCastProjectile {
579                by_entity_id: target.id,
580                to_entity_id: caster.id,
581                projectile_id: Uuid::parse_str(COUNTERATTACK_PROJECTILE).unwrap(),
582                level: 1,
583                delay: 0,
584            },
585        )?;
586    }
587
588    if stat_throw(rng, lookups, caster, "deceit", 0.0) {
589        let pool = ["vulnerability", "weakness"];
590        let idx = rng.random_index(pool.len());
591        let code = pool[idx];
592        change_entity_effect_duration(
593            sink,
594            lookups,
595            effects,
596            target,
597            code,
598            (balance::DECEIT_DEBUFF_DURATION * 1000.0).floor() as i64,
599        )?;
600    }
601
602    let attack = get_entity_stat(lookups, caster, "attack");
603
604    let mut dmg_mod = 1.0;
605    let is_crit = match params.is_crit {
606        Some(v) => v,
607        None => stat_throw(
608            rng,
609            lookups,
610            caster,
611            "crit_chance",
612            params.crit_chance_bonus,
613        ),
614    };
615    if is_crit {
616        dmg_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
617    }
618
619    // Balance v2 (Phase 5): soft 3-cycle class counter — ±swing when the
620    // caster's class beats/loses to the target's. 1.0 for PvE (classless mob)
621    // or same/neutral class, so this is PvP-only by construction.
622    let counter = balance::class_counter_multiplier(lookups, caster.class_id, target.class_id);
623
624    let mut dmg_dealt = None;
625    if let Some(power) = params.power {
626        let dmg = attack * power * dmg_mod * counter;
627        let mut data = CustomEventData::default();
628        if is_crit {
629            data.add("crit", 1);
630        }
631        dmg_dealt = damage_entity(sink, rng, lookups, target, dmg * balance::DMG_K, data)?;
632    }
633    if let Some(dot_power) = params.dot_power {
634        apply_entity_over_time_effect(
635            sink,
636            target,
637            attack * dot_power * dmg_mod * counter * balance::DMG_K,
638            "dot",
639        )?;
640    }
641
642    if caster.id == player_id {
643        let power = params.power.unwrap_or(0.0);
644        if is_crit || power > 3.5 {
645            let _ = screen_shake(sink, 25);
646        }
647    }
648    Ok(dmg_dealt)
649}
650
651/// Typed form of the `spell_heal` params.
652#[derive(Clone, Debug, Default)]
653pub struct SpellHealParams {
654    pub is_crit: Option<bool>,
655    pub power: Option<f64>,
656    pub hot_power: Option<f64>,
657}
658
659impl SpellHealParams {
660    pub fn default_power() -> Self {
661        Self {
662            power: Some(1.0),
663            ..Default::default()
664        }
665    }
666}
667
668/// Native: heal `target` from `caster`'s attack stat. Returns the applied heal
669/// (`None` when no heal). Consumes `crit_chance` from `rng` (unless `is_crit` is
670/// forced), then `heal_entity`'s path (no RNG).
671pub fn spell_heal(
672    sink: &mut dyn FightSink,
673    rng: &GameRng,
674    lookups: &ContentLookups,
675    caster: &Entity,
676    target: &Entity,
677    params: &SpellHealParams,
678) -> Result<Option<i64>, anyhow::Error> {
679    let attack = get_entity_stat(lookups, caster, "attack");
680    let mut heal_mod = 1.0;
681    let is_crit = match params.is_crit {
682        Some(v) => v,
683        None => stat_throw(rng, lookups, caster, "crit_chance", 0.0),
684    };
685    if is_crit {
686        heal_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
687    }
688    let mut heal_event = None;
689    if let Some(power) = params.power {
690        heal_event = heal_entity(sink, target, attack * power * heal_mod * balance::DMG_K)?;
691    }
692    if let Some(hot_power) = params.hot_power {
693        apply_entity_over_time_effect(
694            sink,
695            target,
696            attack * hot_power * heal_mod * balance::DMG_K,
697            "hot",
698        )?;
699    }
700    Ok(heal_event)
701}
702
703/// Native: apply `effect_code` to `target` for `duration_seconds` (converted to
704/// ticks). Effect `on_apply` reactions are dispatched through `effects`.
705pub fn apply_entity_effect(
706    sink: &mut dyn FightSink,
707    lookups: &ContentLookups,
708    effects: &mut dyn EffectCb,
709    target: &Entity,
710    effect_code: &str,
711    duration_seconds: f64,
712) -> Result<(), anyhow::Error> {
713    change_entity_effect_duration(
714        sink,
715        lookups,
716        effects,
717        target,
718        effect_code,
719        (duration_seconds * 1000.0).floor() as i64,
720    )
721}
722
723/// Native: add `duration` ticks of `effect_code` to `target` (capped at the
724/// effect's `max_duration_ticks`), emitting `EntityApplyEffect` on first
725/// application and dispatching the effect's `on_apply` reaction.
726pub fn change_entity_effect_duration(
727    sink: &mut dyn FightSink,
728    lookups: &ContentLookups,
729    effects: &mut dyn EffectCb,
730    target: &Entity,
731    effect_code: &str,
732    duration: i64,
733) -> Result<(), anyhow::Error> {
734    let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
735        Some(t) => t.clone(),
736        None => {
737            return Err(anyhow::anyhow!(
738                "apply_entity_effect: unknown effect code {effect_code}"
739            ));
740        }
741    };
742
743    let duration_attr = format!("effect.{effect_code}.duration");
744    let current_duration_ticks = attr_get(target, &duration_attr) as i64;
745    let max_duration = effect_tpl.max_duration_ticks.unwrap_or(5000);
746    let new_duration = (current_duration_ticks + duration).min(max_duration);
747    incr_attr(
748        sink,
749        target.id,
750        &duration_attr,
751        new_duration - current_duration_ticks,
752    )?;
753    if current_duration_ticks == 0 {
754        push_event(
755            sink,
756            OverlordEventEntityApplyEffect {
757                entity_id: target.id,
758                effect_id: effect_tpl.id,
759            },
760        )?;
761    }
762    effects.on_apply(sink, &effect_tpl, target)?;
763    Ok(())
764}
765
766/// Native: apply a damage-/heal-over-time effect (`kind` = `"dot"` / `"hot"`),
767/// splitting `amount` over 5 ticks. No effect callbacks, no RNG.
768pub fn apply_entity_over_time_effect(
769    sink: &mut dyn FightSink,
770    target: &Entity,
771    amount: f64,
772    kind: &str,
773) -> Result<(), anyhow::Error> {
774    let effect_id = if kind == "dot" {
775        Uuid::parse_str(DOT_EFFECT_ID).unwrap()
776    } else {
777        Uuid::parse_str(HOT_EFFECT_ID).unwrap()
778    };
779    let per_tick = (amount / 5.0).floor() as i64;
780    for i in 1..=5 {
781        let tick_attr = format!("effect.{kind}.tick.{i}");
782        incr_attr(sink, target.id, &tick_attr, per_tick)?;
783    }
784    let next_attr = format!("effect.{kind}.next");
785    if !attr_present(target, &next_attr) {
786        // set to 1 (current is 0)
787        incr_attr(sink, target.id, &next_attr, 1)?;
788        push_event(
789            sink,
790            OverlordEventEntityApplyEffect {
791                entity_id: target.id,
792                effect_id,
793            },
794        )?;
795    }
796    Ok(())
797}
798
799/// Native: remove all stacks of `effect_code` from `target`, dispatching the
800/// effect's `on_change` reaction with `(new_stacks=0, old_stacks=current)`.
801pub fn remove_entity_effect(
802    sink: &mut dyn FightSink,
803    lookups: &ContentLookups,
804    effects: &mut dyn EffectCb,
805    target: &Entity,
806    effect_code: &str,
807) -> Result<(), anyhow::Error> {
808    let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
809        Some(t) => t.clone(),
810        None => {
811            return Err(anyhow::anyhow!(
812                "remove_entity_effect: unknown effect code {effect_code}"
813            ));
814        }
815    };
816    let stacks_attr = format!("effect.{effect_code}.stacks");
817    let current_stacks = attr_get(target, &stacks_attr) as i64;
818    incr_attr(sink, target.id, &stacks_attr, -current_stacks)?;
819    effects.on_change(sink, &effect_tpl, target, 0, current_stacks)?;
820    Ok(())
821}
822
823/// Native: emit the per-entity init events for a fight (regen effect, low-chapter
824/// effect, party power adjustment, dungeon/boss talent attack mods). No RNG, no
825/// effect callbacks. `fight` supplies player/party ids + the entity roster +
826/// `party_adjusted_power`; reads talent levels / chapter from `state`.
827#[allow(clippy::too_many_arguments)]
828pub fn init_fight(
829    sink: &mut dyn FightSink,
830    lookups: &ContentLookups,
831    fight: &ActiveFight,
832    state: &OverlordState,
833    fight_template_id: Uuid,
834) -> Result<(), anyhow::Error> {
835    let is_dungeon = lookups
836        .fight_template_is_dungeon
837        .get(&fight_template_id)
838        .copied()
839        .unwrap_or(false);
840    let is_bossfight = lookups
841        .fight_template_is_bossfight
842        .get(&fight_template_id)
843        .copied()
844        .unwrap_or(false);
845
846    let player_id = fight.player_id;
847    let party_player_id = fight.party_player_id;
848    let entities = fight.entities.clone();
849    let regen_effect = Uuid::parse_str(REGEN_EFFECT_ID).unwrap();
850    let low_chapter_effect = Uuid::parse_str(LOW_CHAPTER_EFFECT_ID).unwrap();
851    let dungeon_talent = Uuid::parse_str(DUNGEON_TALENT_ID).unwrap();
852    let boss_talent = Uuid::parse_str(BOSS_TALENT_ID).unwrap();
853
854    for entity in &entities {
855        if attr_present(entity, "regeneration_rate") {
856            push_event(
857                sink,
858                OverlordEventEntityApplyEffect {
859                    entity_id: entity.id,
860                    effect_id: regen_effect,
861                },
862            )?;
863        }
864
865        let mut dungeon_level: Option<i64> = None;
866        let mut boss_level: Option<i64> = None;
867
868        if entity.id == player_id {
869            dungeon_level = state
870                .character_state
871                .talent_levels
872                .get(&dungeon_talent)
873                .copied();
874            boss_level = state
875                .character_state
876                .talent_levels
877                .get(&boss_talent)
878                .copied();
879            if state.character_state.character.current_chapter_level
880                <= LOW_CHAPTER_EFFECT_MAX_CHAPTER
881            {
882                push_event(
883                    sink,
884                    OverlordEventEntityApplyEffect {
885                        entity_id: entity.id,
886                        effect_id: low_chapter_effect,
887                    },
888                )?;
889            }
890        }
891        if Some(entity.id) == party_player_id {
892            let party = &state.party;
893            if let Some(party_state) = &party.party_state {
894                dungeon_level = party_state.talent_levels.get(&dungeon_talent).copied();
895                boss_level = party_state.talent_levels.get(&boss_talent).copied();
896                if let Some(adjusted) = party.party_adjusted_power {
897                    let raw = party_state.character.power as f64;
898                    if raw > 0.0 {
899                        let adjust_k = adjusted as f64 / raw;
900                        let stat_k = adjust_k.sqrt();
901                        let current_attack = attr_get(entity, "attack");
902                        let delta =
903                            (current_attack * stat_k).floor() as i64 - current_attack as i64;
904                        incr_attr(sink, entity.id, "attack", delta)?;
905                        let new_max_hp = ((entity.hp as f64) * stat_k).floor() as u64;
906                        push_event(
907                            sink,
908                            OverlordEventSetMaxHp {
909                                entity_id: entity.id,
910                                new_max_hp,
911                                new_hp: new_max_hp,
912                            },
913                        )?;
914                    }
915                }
916            }
917        }
918
919        if is_dungeon && let Some(level) = dungeon_level {
920            let current = attr_get(entity, "attack.mod") as i64;
921            incr_attr(sink, entity.id, "attack.mod", 200 * level - current)?;
922        }
923        if is_bossfight {
924            // Boss fights scale `attack.mod` by the player's BOSS talent level.
925            // talent being present while reading the boss talent level;
926            // corrected here to gate on the boss talent itself.)
927            if let Some(level) = boss_level {
928                let current = attr_get(entity, "attack.mod") as i64;
929                incr_attr(sink, entity.id, "attack.mod", 200 * level - current)?;
930            }
931        }
932    }
933    Ok(())
934}
935
936// ---------------------------------------------------------------------------
937// spawn_wave & simulated_wave helpers
938// ---------------------------------------------------------------------------
939
940/// Typed form of the `fight_data` prepare-fight blob fed to `spawn_wave`.
941#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
942pub struct WaveFightData {
943    /// `entities[].{ entity_id, power }` — the per-template relative powers.
944    pub entities: Vec<WaveEntityPower>,
945    /// `waves[][].{ data: { entity_id, delay }, position }`.
946    pub waves: Vec<Vec<WaveSpawn>>,
947    /// `time` — the fight time budget used to normalise mob HP.
948    pub time: f64,
949    /// The per-fight reference power (`base_power` for `spawn_wave`).
950    /// `None` mirrors a missing/unconfigured value (treated as 0.0 by callers).
951    pub power: Option<f64>,
952}
953
954#[derive(Clone, Debug, PartialEq, serde::Serialize)]
955pub struct WaveEntityPower {
956    /// `None` mirrors an entry with no readable `entity_id`. The original
957    /// `min_power` pass ignores `entity_id` entirely (so such an entry can still
958    /// lower `min_power`), while the `entity_powers` pass skips it — both
959    /// behaviours are preserved by keeping this optional.
960    pub entity_id: Option<String>,
961    /// `None` mirrors a missing/unreadable `power`. The original `min_power`
962    /// pass skips a missing power; the `entity_powers` pass defaults it to 1.0.
963    pub power: Option<f64>,
964}
965
966#[derive(Clone, Debug, PartialEq, serde::Serialize)]
967pub struct WaveSpawn {
968    pub entity_id: String,
969    /// `delay` (ms). `None` mirrors a missing key (treated as 0.0 in sims, and
970    /// the spawn loop reads it as `unwrap_or(0.0) as i64`).
971    pub delay: Option<f64>,
972    /// `None` mirrors an unreadable `position`. The original only reads the
973    /// position in the final spawn loop (current wave), where a missing one is
974    /// an error; spawns in other waves never have their position validated, so
975    /// this stays optional to preserve that error timing.
976    pub position: Option<Coordinates>,
977}
978
979#[derive(Clone, Debug)]
980struct SimEnemy {
981    eff_damage: f64,
982    ttk: f64,
983    delay: f64,
984}
985
986fn sim_wave_choose_next_target(enemies: &[SimEnemy]) -> Option<usize> {
987    let mut fastest = 1800.0f64;
988    let mut idx = None;
989    for (i, e) in enemies.iter().enumerate() {
990        if e.delay <= 0.0 && e.ttk < fastest {
991            idx = Some(i);
992            // Track the lowest time-to-kill among the arrived enemies so we
993            // pick the fastest-dying target. (The original legacy script stored
994            // `enemy.delay` here — always 0 for arrived enemies — which pinned
995            // `fastest` to 0 and made the loop always keep the FIRST arrived
996            // enemy regardless of ttk; corrected to `e.ttk`.)
997            fastest = e.ttk;
998        }
999    }
1000    idx
1001}
1002
1003fn sim_wave_choose_sim_time(enemies: &[SimEnemy], current_target: Option<usize>) -> f64 {
1004    let mut next_step = 1800.0f64;
1005    for e in enemies {
1006        if e.delay > 0.0 && e.delay < next_step {
1007            next_step = e.delay;
1008        }
1009    }
1010    if let Some(idx) = current_target
1011        && let Some(t) = enemies.get(idx)
1012        && t.ttk < next_step
1013    {
1014        next_step = t.ttk;
1015    }
1016    next_step
1017}
1018
1019fn sim_wave_advance(enemies: &mut Vec<SimEnemy>, mob_damage: &mut f64) {
1020    let target = sim_wave_choose_next_target(enemies);
1021    let time = sim_wave_choose_sim_time(enemies, target);
1022    if let Some(t) = target {
1023        // Total per-second damage of the arrived enemies. Matches the shipped
1024        // legacy `reduce(|sum| sum + this.eff_damage, 0)` script — in that
1025        // language's array closures `this` binds to the CURRENT ELEMENT, so the
1026        // original did compute this exact per-enemy sum (the #2046 note calling
1027        // it broken misread that binding; the code below was already parity).
1028        let dmg: f64 = enemies
1029            .iter()
1030            .filter(|e| e.delay <= 0.0)
1031            .map(|e| e.eff_damage)
1032            .sum();
1033        *mob_damage += dmg * time;
1034        enemies[t].ttk -= time;
1035        if enemies[t].ttk <= 0.0 {
1036            enemies.remove(t);
1037        }
1038    }
1039    for e in enemies.iter_mut() {
1040        if e.delay > 0.0 {
1041            e.delay = (e.delay - time).max(0.0);
1042        }
1043    }
1044}
1045
1046/// Native: the `prepare_fight` interpreter. Computes normalised enemy stats from
1047/// the chapter power curve + the typed wave blob, then emits spawn events for the
1048/// current wave. RNG is consumed once per spawned enemy (the spawn-entity uuid),
1049/// in iteration order — identical to the original. `fight` supplies
1050/// `current_wave` + the live `entities` (for the multi-wave x-offset).
1051#[allow(clippy::too_many_arguments)]
1052pub fn spawn_wave(
1053    sink: &mut dyn FightSink,
1054    rng: &GameRng,
1055    config: &GameConfig,
1056    lookups: &ContentLookups,
1057    fight: &ActiveFight,
1058    fight_data: &WaveFightData,
1059    base_power: f64,
1060    current_chapter: i64,
1061    fight_type: &str,
1062) -> Result<(), anyhow::Error> {
1063    const POWER_EFF_HP: f64 = 0.75;
1064    const POWER_ATTACK: f64 = 1.0 - POWER_EFF_HP;
1065
1066    // Campaign enemy power follows the geometric curve past the hand-made
1067    // chapters, via `balance::enemy_power_scalar` — shared with the battle-end
1068    // analytics so the report stamps exactly what spawned. The `eff` divide below
1069    // is float (no integer truncation).
1070    let is_dungeon = lookups
1071        .fight_template_is_dungeon
1072        .get(&fight.fight_id)
1073        .copied()
1074        .unwrap_or(false);
1075    let power = balance::enemy_power_scalar(base_power, current_chapter, fight_type, is_dungeon);
1076
1077    let hp_k = balance::hp_k_for_chapter(config, current_chapter);
1078    let eff = power / balance::BASE_POWER as f64;
1079    let mut eff_hp = balance::BASE_HP * (eff * hp_k).powf(0.5);
1080    let mut eff_attack = balance::BASE_ATTACK * (eff / hp_k).powf(0.5);
1081
1082    eff_hp *= 2.0_f64.powf(0.5);
1083    eff_attack /= 2.0_f64.powf(0.5);
1084
1085    let player_dps = eff_attack * balance::BASE_SPELL_EFF * balance::DMG_K;
1086
1087    // `min_power` over all readable powers (the original ignored `entity_id`
1088    // here, so an entry with a power but no id can still lower the minimum).
1089    let mut min_power: Option<f64> = None;
1090    for ent in &fight_data.entities {
1091        if let Some(p) = ent.power {
1092            min_power = Some(min_power.map(|mp| mp.min(p)).unwrap_or(p));
1093        }
1094    }
1095    let min_power = min_power.unwrap_or(1.0);
1096
1097    // `entity_powers` keyed by id (entries with no id are skipped); a missing
1098    // power defaults to 1.0.
1099    let mut entity_powers: std::collections::HashMap<String, f64> =
1100        std::collections::HashMap::new();
1101    for ent in &fight_data.entities {
1102        let Some(id) = &ent.entity_id else {
1103            continue;
1104        };
1105        let p = ent.power.unwrap_or(1.0);
1106        entity_powers.insert(id.clone(), p / min_power);
1107    }
1108
1109    let mut mobs_eff_hp = 0.0f64;
1110    for wave in &fight_data.waves {
1111        for spawn in wave {
1112            let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
1113            mobs_eff_hp += enemy_power.powf(POWER_EFF_HP);
1114        }
1115    }
1116
1117    let time = fight_data.time;
1118    let player_damage = player_dps * time;
1119    let mob_hp_norm = if mobs_eff_hp > 0.0 {
1120        player_damage / mobs_eff_hp
1121    } else {
1122        0.0
1123    };
1124    let mob_norm_ttk = if player_dps > 0.0 {
1125        mob_hp_norm / player_dps
1126    } else {
1127        0.0
1128    };
1129
1130    let mut overall_damage = 0.0f64;
1131    for wave in &fight_data.waves {
1132        let mut sim_enemies: Vec<SimEnemy> = Vec::new();
1133        for spawn in wave {
1134            let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
1135            let delay = spawn.delay.unwrap_or(0.0);
1136            sim_enemies.push(SimEnemy {
1137                eff_damage: enemy_power.powf(POWER_ATTACK),
1138                ttk: mob_norm_ttk * enemy_power.powf(POWER_EFF_HP),
1139                delay,
1140            });
1141        }
1142        let mut mob_damage = 0.0f64;
1143        let mut iter = 0;
1144        while !sim_enemies.is_empty() && iter < 200 {
1145            iter += 1;
1146            sim_wave_advance(&mut sim_enemies, &mut mob_damage);
1147        }
1148        overall_damage += mob_damage;
1149    }
1150
1151    let mob_dps_norm = if overall_damage > 0.0 {
1152        eff_hp / overall_damage
1153    } else {
1154        0.0
1155    };
1156    let mob_attack_norm = mob_dps_norm / balance::BASE_SPELL_EFF / balance::DMG_K;
1157
1158    let wave_idx = (fight.current_wave - 1) as usize;
1159    let mut offset_x = 0i64;
1160    if wave_idx > 0 {
1161        let mut max_ally_x: Option<i64> = None;
1162        for e in &fight.entities {
1163            if e.team == EntityTeam::Ally && e.hp > 0 {
1164                max_ally_x = Some(
1165                    max_ally_x
1166                        .map(|m| m.max(e.coordinates.x))
1167                        .unwrap_or(e.coordinates.x),
1168                );
1169            }
1170        }
1171        if let Some(m) = max_ally_x {
1172            offset_x = m - 1 + 6;
1173        }
1174    }
1175
1176    let Some(current_wave) = fight_data.waves.get(wave_idx) else {
1177        return Ok(());
1178    };
1179
1180    for spawn in current_wave {
1181        let enemy_tpl_id = match Uuid::parse_str(&spawn.entity_id) {
1182            Ok(u) => u,
1183            Err(_) => continue,
1184        };
1185        let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
1186        let enemy_hp_norm = enemy_power.powf(POWER_EFF_HP);
1187        let enemy_attack_norm = enemy_power.powf(POWER_ATTACK);
1188        let enemy_hp = enemy_hp_norm * mob_hp_norm;
1189        let enemy_attack = enemy_attack_norm * mob_attack_norm;
1190        let delay = spawn.delay.unwrap_or(0.0) as i64;
1191
1192        let mut attrs = EntityAttributes::default();
1193        attrs.add("attack", enemy_attack.floor() as i64);
1194        attrs.add("hp", enemy_hp.floor() as i64);
1195        attrs.add("speed", 10000);
1196        if delay > 0 {
1197            attrs.add("wake_up_delay", delay);
1198        }
1199
1200        let base_position = spawn
1201            .position
1202            .clone()
1203            .ok_or_else(|| anyhow::anyhow!("spawn_wave: spawn entry has no readable position"))?;
1204        let position = Coordinates {
1205            x: base_position.x + offset_x,
1206            y: base_position.y,
1207        };
1208
1209        let has_big_hp_bar = lookups
1210            .entity_template_is_boss
1211            .get(&enemy_tpl_id)
1212            .copied()
1213            .unwrap_or(false);
1214
1215        let spawn_evt = OverlordEventSpawnEntity {
1216            id: uuid::Builder::from_random_bytes(rng.random_bytes()).into_uuid(),
1217            entity_template_id: enemy_tpl_id,
1218            position,
1219            entity_team: EntityTeam::Enemy,
1220            has_big_hp_bar,
1221            entity_attributes: attrs,
1222        };
1223        let entity_id = spawn_evt.id;
1224        push_event(sink, spawn_evt)?;
1225
1226        if delay > 0 {
1227            // `wake_up_delay` is already set on the spawn attrs above; the
1228            // `wake_up` effect counts it down 1/sec until the enemy wakes. (The
1229            // pre-migration code incremented it a SECOND time here, doubling the
1230            // sleep duration so delayed enemies stayed transparent ~2x as long as
1231            // configured — dropped.)
1232            incr_attr(sink, entity_id, "sleep", 1)?;
1233            push_event(
1234                sink,
1235                OverlordEventEntityApplyEffect {
1236                    entity_id,
1237                    effect_id: Uuid::parse_str(SLEEP_EFFECT_ID).unwrap(),
1238                },
1239            )?;
1240        }
1241    }
1242    Ok(())
1243}
1244
1245/// Convert the typed config mirror (`prepare_fight_waves`) into the runtime
1246/// [`WaveFightData`] consumed by [`spawn_wave`]. This is the native data
1247/// source for the prepare_fight interpreter.
1248pub fn wave_data_from_config(cfg: &essences::fighting::PrepareFightWaves) -> WaveFightData {
1249    WaveFightData {
1250        entities: cfg
1251            .entities
1252            .iter()
1253            .map(|e| WaveEntityPower {
1254                entity_id: e.entity_id.clone(),
1255                power: e.power,
1256            })
1257            .collect(),
1258        waves: cfg
1259            .waves
1260            .iter()
1261            .map(|wave| {
1262                wave.iter()
1263                    .map(|s| WaveSpawn {
1264                        entity_id: s.entity_id.clone(),
1265                        // The deployed prepare_fight always carried an explicit
1266                        // `delay: 0` for spawns that omit it; mirror that default
1267                        // here (the typed config omits the 0s).
1268                        delay: Some(s.delay.unwrap_or(0.0)),
1269                        position: s.position.clone(),
1270                    })
1271                    .collect()
1272            })
1273            .collect(),
1274        time: cfg.time,
1275        power: Some(cfg.power),
1276    }
1277}
1278
1279// ---------------------------------------------------------------------------
1280// Casts and movement
1281// ---------------------------------------------------------------------------
1282
1283fn is_valid_target(
1284    lookups: &ContentLookups,
1285    target: &Entity,
1286    ability: &Ability,
1287    caster: &Entity,
1288) -> bool {
1289    let target_type = lookups
1290        .ability_target_type
1291        .get(&ability.template_id)
1292        .map(|s| s.as_str())
1293        .unwrap_or("");
1294    let by_team = match target_type {
1295        "Enemy" => target.team != caster.team,
1296        "Ally" => target.team == caster.team,
1297        _ => false,
1298    };
1299    let range = lookups
1300        .ability_range
1301        .get(&ability.template_id)
1302        .copied()
1303        .unwrap_or(0);
1304    by_team && get_distance_to(caster, target) <= range
1305}
1306
1307/// Native: queue `casts` attack actions for `caster` against `valid_targets`.
1308/// Consumes `multicast_chance` from `rng`, then (per cast after the first, when
1309/// there are multiple targets) a `randint` for target selection. `_ability` is
1310/// unused, matching the original signature.
1311#[allow(clippy::too_many_arguments)]
1312pub fn cast(
1313    sink: &mut dyn FightSink,
1314    rng: &GameRng,
1315    config: &GameConfig,
1316    lookups: &ContentLookups,
1317    caster: &Entity,
1318    _ability: &Ability,
1319    valid_targets: &[Entity],
1320    natural_casts_number: i64,
1321) -> Result<(), anyhow::Error> {
1322    let mut casts_number = natural_casts_number;
1323    let is_multicast = stat_throw(rng, lookups, caster, "multicast_chance", 0.0);
1324    if is_multicast {
1325        casts_number *= 2;
1326    }
1327    let base_cast_time = entity_get_tpl_cast_time(config, caster) as f64;
1328    let cast_time = (base_cast_time / casts_number as f64).floor() as i64;
1329    for i in 0..casts_number {
1330        let target = if valid_targets.len() > 1 {
1331            if i == 0 {
1332                get_target(caster, valid_targets)
1333                    .cloned()
1334                    .unwrap_or_else(|| valid_targets[0].clone())
1335            } else {
1336                let idx = rng.random_index(valid_targets.len());
1337                valid_targets[idx].clone()
1338            }
1339        } else {
1340            valid_targets[0].clone()
1341        };
1342        sink.push_attack(
1343            (i * cast_time).max(0) as u64,
1344            cast_time.max(0) as u64,
1345            target.id,
1346        )?;
1347    }
1348    Ok(())
1349}
1350
1351/// Native: the ability `on_cast` hook. Rolls `bravery`; on success applies a
1352/// random buff (`protection`/`empower`). Consumes `bravery` then (if it lands)
1353/// a `randint(0,2)` from `rng`. Effect `on_apply` reactions dispatch via
1354/// `effects`.
1355pub fn on_cast(
1356    sink: &mut dyn FightSink,
1357    rng: &GameRng,
1358    lookups: &ContentLookups,
1359    effects: &mut dyn EffectCb,
1360    caster: &Entity,
1361) -> Result<(), anyhow::Error> {
1362    if stat_throw(rng, lookups, caster, "bravery", 0.0) {
1363        let pool = ["protection", "empower"];
1364        let idx = rng.random_index(pool.len());
1365        let code = pool[idx];
1366        apply_entity_effect(
1367            sink,
1368            lookups,
1369            effects,
1370            caster,
1371            code,
1372            balance::BRAVERY_BUFF_DURATION,
1373        )?;
1374    }
1375    Ok(())
1376}
1377
1378/// Native: move `entity` toward the enemy line in a single multi-cell run
1379/// (one `StartMove` instead of one per cell — every per-cell seam quantizes
1380/// to the 100ms game tick and gives the client a chance to stutter).
1381///
1382/// Movement is fixed-direction (allies always advance +x, enemies −x), so we
1383/// only ever advance toward the nearest opponent *ahead* — never chase one we
1384/// have already reached or passed, which would march us away from the fight
1385/// forever. Against a mobile opponent we cover **half the gap** and against a
1386/// static one the **whole gap** (see the `max_steps` comment): both produce one
1387/// `StartMove`, so an approach is a single smooth run, not one per cell.
1388///
1389/// Two mutually-approaching mobile runners meet in the middle without landing
1390/// on or crossing each other: at fight start both plan on the same tick, each
1391/// covers its half, and they end adjacent. A `Run` lowers to an *immediate*
1392/// `StartMove` and the event loop is depth-first LIFO, so the first planner's
1393/// `handle_start_move` sets its `move_target` reservation BEFORE the second
1394/// plans this tick; the second's probe routes every cell through
1395/// `cell_exists_and_free` (checks coordinates AND reserved `move_target`), so
1396/// when both aim at the same midpoint the second takes the cell beside it.
1397///
1398/// (The entrance "run onto the screen → run in the fight" seam is a separate,
1399/// client-side concern — Unity's run-in tween must flow into the first
1400/// `StartMove` instead of force-settling to Idle; no server distance can cover
1401/// it.)
1402///
1403/// The walk also stops on the first cell where `ability` gains a valid target,
1404/// and never steps onto a cell occupied or reserved by another entity.
1405///
1406/// Neither team may cross the other's front line. A mover advances at most to
1407/// the column directly in front of the opposing team's frontmost LIVING unit and
1408/// never onto or past it, so the two sides meet exactly one column apart instead
1409/// of stacking on the same column (an enemy standing right under the hero). The
1410/// opposing front is taken over both current cells AND reserved `move_target`s,
1411/// so two mutual approachers don't both land on the same midpoint column on an
1412/// even gap: the depth-first / LIFO second planner sees the first's reservation
1413/// and stops one column short. This is the hard guarantee layered on top of the
1414/// half-gap / `max_steps` heuristic and the in-range stop.
1415pub fn advance_entity(
1416    sink: &mut dyn FightSink,
1417    config: &GameConfig,
1418    lookups: &ContentLookups,
1419    fight: &ActiveFight,
1420    entity: &Entity,
1421    ability: &Ability,
1422) -> Result<(), anyhow::Error> {
1423    let direction: i64 = if entity.team == EntityTeam::Ally {
1424        1
1425    } else {
1426        -1
1427    };
1428
1429    // Current x-distance ahead; opponents behind (already passed) are ignored.
1430    let forward = |opp: &Entity| (opp.coordinates.x - entity.coordinates.x) * direction;
1431
1432    let Some(opponent) = fight
1433        .entities
1434        .iter()
1435        .filter(|ent| ent.team != entity.team)
1436        .filter(|ent| forward(ent) > 0)
1437        .min_by_key(|ent| forward(ent))
1438    else {
1439        return Ok(());
1440    };
1441
1442    let gap = forward(opponent);
1443    // Half the gap toward a mobile opponent (it advances too, so both cover
1444    // their half and meet in the middle — at fight start both plan on the same
1445    // tick, so it is ONE run each), and the whole gap against a static one
1446    // (which never closes, so creeping in half-at-a-time would chain runs).
1447    // Full-gap toward a *mobile* opponent is wrong: both would commit a run to
1448    // adjacent of the other's current cell and pass through each other on
1449    // different rows. The reservation keeps two mid-meeters off the same cell.
1450    let max_steps = if attr_present(opponent, "static") {
1451        (gap - 1).max(0)
1452    } else {
1453        gap / 2
1454    };
1455
1456    // Front line of the opposing team facing this mover: the extreme x in the
1457    // mover's travel direction (the MIN opposing x for a +x mover, the MAX for a
1458    // -x mover), taken over both current cells AND reserved `move_target`s. The
1459    // mover may approach to one column in front of it (`front - direction`) and
1460    // no closer, so the two sides meet one column apart and never share a column.
1461    let opp_team = match entity.team {
1462        EntityTeam::Ally => EntityTeam::Enemy,
1463        EntityTeam::Enemy => EntityTeam::Ally,
1464    };
1465    let mut opp_front: Option<i64> = None;
1466    for e in &fight.entities {
1467        if e.team != opp_team || e.hp == 0 {
1468            continue;
1469        }
1470        for x in std::iter::once(e.coordinates.x).chain(e.move_target.as_ref().map(|t| t.x)) {
1471            opp_front = Some(match (opp_front, direction > 0) {
1472                (Some(cur), true) => cur.min(x),
1473                (Some(cur), false) => cur.max(x),
1474                (None, _) => x,
1475            });
1476        }
1477    }
1478    let front_limit = opp_front.map(|front| front - direction);
1479
1480    let mut probe = entity.clone();
1481    for _ in 0..max_steps {
1482        let Some(next) = find_advance_coordinates(&probe, &fight.entities, config) else {
1483            break;
1484        };
1485        // Stop one column short of the opposing front — never cross it.
1486        if let Some(limit) = front_limit
1487            && (next.x - limit) * direction > 0
1488        {
1489            break;
1490        }
1491        probe.coordinates = next;
1492        if fight
1493            .entities
1494            .iter()
1495            .any(|ent| is_valid_target(lookups, ent, ability, &probe))
1496        {
1497            break;
1498        }
1499    }
1500
1501    if probe.coordinates == entity.coordinates {
1502        return Ok(());
1503    }
1504    entity_run(sink, lookups, entity, probe.coordinates)
1505}
1506
1507/// Native: emit a run action moving `entity` to `to` (no-op if speed is 0 or
1508/// the move covers no distance).
1509pub fn entity_run(
1510    sink: &mut dyn FightSink,
1511    lookups: &ContentLookups,
1512    entity: &Entity,
1513    to: Coordinates,
1514) -> Result<(), anyhow::Error> {
1515    const DEFAULT_TIME_PER_CELL: f64 = 500.0;
1516    let speed = get_entity_stat(lookups, entity, "speed");
1517    if speed == 0.0 {
1518        return Ok(());
1519    }
1520    let speed_norm = speed / 10000.0;
1521    // Euclidean, not |dx|: a diagonal sidestep covers √2 cells and must take
1522    // √2× the time, otherwise the unit visibly speeds up on lane changes.
1523    let dx = (to.x - entity.coordinates.x) as f64;
1524    let dy = (to.y - entity.coordinates.y) as f64;
1525    let distance = (dx * dx + dy * dy).sqrt();
1526    if distance == 0.0 {
1527        return Ok(());
1528    }
1529    let time_per_cell = DEFAULT_TIME_PER_CELL / speed_norm;
1530    let duration = (time_per_cell * distance).floor() as u64;
1531    sink.push_run(to, duration)
1532}
1533
1534/// Native: attempt to cast `ability_id` from `caster`. Picks valid targets and
1535/// delegates to `cast`; if there are none and the caster is not `static`,
1536/// advances toward the enemy. `casts` is the natural cast count (default 1).
1537/// Consumes RNG only via `cast` / `advance_entity`.
1538#[allow(clippy::too_many_arguments)]
1539pub fn try_cast(
1540    sink: &mut dyn FightSink,
1541    rng: &GameRng,
1542    config: &GameConfig,
1543    lookups: &ContentLookups,
1544    fight: &ActiveFight,
1545    caster: &Entity,
1546    ability_id: Uuid,
1547    casts: i64,
1548) -> Result<(), anyhow::Error> {
1549    if attr_present(caster, "sleep") {
1550        return Ok(());
1551    }
1552    if config.ability_template(ability_id).is_none() {
1553        return Err(anyhow::anyhow!("try_cast: unknown ability {ability_id}"));
1554    }
1555    let target_type = lookups
1556        .ability_target_type
1557        .get(&ability_id)
1558        .cloned()
1559        .unwrap_or_default();
1560    let ability_val = Ability {
1561        template_id: ability_id,
1562        level: 1,
1563        shards_amount: 0,
1564    };
1565    if target_type == "Self" {
1566        cast(
1567            sink,
1568            rng,
1569            config,
1570            lookups,
1571            caster,
1572            &ability_val,
1573            std::slice::from_ref(caster),
1574            casts,
1575        )?;
1576        return Ok(());
1577    }
1578    let mut valid_targets = Vec::new();
1579    for ent in &fight.entities {
1580        if is_valid_target(lookups, ent, &ability_val, caster) {
1581            valid_targets.push(ent.clone());
1582        }
1583    }
1584    if !valid_targets.is_empty() {
1585        cast(
1586            sink,
1587            rng,
1588            config,
1589            lookups,
1590            caster,
1591            &ability_val,
1592            &valid_targets,
1593            casts,
1594        )?;
1595        return Ok(());
1596    }
1597    if !attr_present(caster, "static") {
1598        advance_entity(sink, config, lookups, fight, caster, &ability_val)?;
1599    }
1600    Ok(())
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605    use super::*;
1606
1607    fn entity_at(team: EntityTeam, x: i64) -> Entity {
1608        Entity {
1609            id: uuid::Uuid::new_v4(),
1610            team,
1611            coordinates: Coordinates { x, y: 1 },
1612            ..Default::default()
1613        }
1614    }
1615
1616    /// `target_type` / `range` must reach `ContentLookups`. When they are absent
1617    /// (the bug), `target_type` resolves to "" → `by_team` is always false → no
1618    /// ability ever has a valid target → entities advance every tick and run
1619    /// through the enemy line without attacking.
1620    #[test]
1621    fn is_valid_target_requires_populated_target_type_and_range() {
1622        let ability_id = uuid::Uuid::new_v4();
1623        let ability = Ability {
1624            template_id: ability_id,
1625            level: 1,
1626            shards_amount: 0,
1627        };
1628        let caster = entity_at(EntityTeam::Ally, 0);
1629        let enemy_in_range = entity_at(EntityTeam::Enemy, 1);
1630        let enemy_out_of_range = entity_at(EntityTeam::Enemy, 5);
1631        let ally = entity_at(EntityTeam::Ally, 1);
1632
1633        // The bug: empty lookups (target_type "" → by_team false) — never valid.
1634        let empty = ContentLookups::default();
1635        assert!(
1636            !is_valid_target(&empty, &enemy_in_range, &ability, &caster),
1637            "empty lookups must yield no valid target (this was the run-through bug)"
1638        );
1639
1640        // Fixed: target_type "Enemy" + range 1 sourced from the typed config.
1641        let mut populated = ContentLookups::default();
1642        populated
1643            .ability_target_type
1644            .insert(ability_id, "Enemy".to_string());
1645        populated.ability_range.insert(ability_id, 1);
1646
1647        assert!(
1648            is_valid_target(&populated, &enemy_in_range, &ability, &caster),
1649            "an in-range enemy must be a valid target once lookups are populated"
1650        );
1651        assert!(
1652            !is_valid_target(&populated, &enemy_out_of_range, &ability, &caster),
1653            "an out-of-range enemy must not be targetable (caster should advance)"
1654        );
1655        assert!(
1656            !is_valid_target(&populated, &ally, &ability, &caster),
1657            "an ally must not be a valid target for an Enemy-typed ability"
1658        );
1659    }
1660
1661    fn lookups_with_speed(speed: f64) -> ContentLookups {
1662        let speed_id = uuid::Uuid::new_v4();
1663        let mut lookups = ContentLookups::default();
1664        lookups
1665            .attribute_by_code
1666            .insert("speed".to_string(), speed_id);
1667        lookups.attribute_base_value.insert(speed_id, speed);
1668        lookups
1669    }
1670
1671    fn advance_lookups(ability_id: uuid::Uuid, range: i64) -> ContentLookups {
1672        let mut lookups = lookups_with_speed(10000.0);
1673        lookups
1674            .ability_target_type
1675            .insert(ability_id, "Enemy".to_string());
1676        lookups.ability_range.insert(ability_id, range);
1677        lookups
1678    }
1679
1680    fn advance_against_enemy_at(enemy_x: i64, range: i64) -> NativeSink {
1681        let ability_id = uuid::Uuid::new_v4();
1682        let ability = Ability {
1683            template_id: ability_id,
1684            level: 1,
1685            shards_amount: 0,
1686        };
1687        let caster = entity_at(EntityTeam::Ally, 0);
1688        let enemy = entity_at(EntityTeam::Enemy, enemy_x);
1689        let lookups = advance_lookups(ability_id, range);
1690        let fight = ActiveFight {
1691            entities: vec![caster.clone(), enemy],
1692            ..Default::default()
1693        };
1694        let config = configs::tests_game_config::generate_game_config_for_tests();
1695
1696        let mut sink = NativeSink::default();
1697        advance_entity(&mut sink, &config, &lookups, &fight, &caster, &ability).unwrap();
1698        sink
1699    }
1700
1701    /// A mobile opponent will advance to meet us, so the run covers half the
1702    /// gap (meet-in-the-middle) in ONE `StartMove` — both sides keep moving
1703    /// rather than one charging all the way in while the other stands. Same-cell
1704    /// overlap between two mid-approachers is prevented by the `move_target`
1705    /// reservation. Enemy at x=5, mobile → ally runs to the midpoint x=2.
1706    #[test]
1707    fn advance_entity_meets_a_mobile_opponent_in_the_middle() {
1708        let sink = advance_against_enemy_at(5, 1);
1709        assert_eq!(sink.casts.len(), 1, "the advance must be a single run");
1710        let run = &sink.casts[0];
1711        assert_eq!(
1712            run.coordinates,
1713            Some(Coordinates { x: 2, y: 1 }),
1714            "must cover half the 5-cell gap (meet in the middle)"
1715        );
1716        assert_eq!(
1717            run.run_duration_ticks,
1718            Some(1000),
1719            "2 cells × 500ms at baseline speed"
1720        );
1721    }
1722
1723    /// A *static* opponent will never close the distance, so the approach must
1724    /// cover the whole gap in one smooth run (one cell short), not creep in
1725    /// half-at-a-time (which would stutter). Enemy at x=5 with the `static`
1726    /// attribute → ally runs all the way to x=4.
1727    #[test]
1728    fn advance_entity_full_approach_against_static_opponent() {
1729        let caster = entity_at(EntityTeam::Ally, 0);
1730        let mut enemy = entity_at(EntityTeam::Enemy, 5);
1731        enemy.attributes.0.insert("static".to_string(), 1);
1732
1733        let sink = advance_in_fight(&caster, vec![enemy], 1);
1734        assert_eq!(sink.casts.len(), 1);
1735        assert_eq!(
1736            sink.casts[0].coordinates,
1737            Some(Coordinates { x: 4, y: 1 }),
1738            "must approach a static enemy all the way to x=4 in one run"
1739        );
1740    }
1741
1742    /// The walk stops as soon as the ability gains a valid target, even
1743    /// before the half-gap budget is spent.
1744    #[test]
1745    fn advance_entity_stops_when_target_comes_into_range() {
1746        let sink = advance_against_enemy_at(4, 3);
1747        assert_eq!(sink.casts.len(), 1);
1748        assert_eq!(
1749            sink.casts[0].coordinates,
1750            Some(Coordinates { x: 1, y: 1 }),
1751            "one step puts the enemy within range 3 — stop there"
1752        );
1753    }
1754
1755    /// Adjacent to the opponent's column (gap 1) there is nowhere to go —
1756    /// every shipped ability has range >= 1, so no run must be emitted and
1757    /// the entity must never enter the opponent's column.
1758    #[test]
1759    fn advance_entity_never_enters_the_opponent_column() {
1760        let sink = advance_against_enemy_at(1, 1);
1761        assert!(
1762            sink.casts.is_empty(),
1763            "gap 1 must produce no run (entity would land on the enemy)"
1764        );
1765    }
1766
1767    fn entity_at_xy(team: EntityTeam, x: i64, y: i64) -> Entity {
1768        Entity {
1769            id: uuid::Uuid::new_v4(),
1770            team,
1771            coordinates: Coordinates { x, y },
1772            ..Default::default()
1773        }
1774    }
1775
1776    fn advance_in_fight(caster: &Entity, others: Vec<Entity>, range: i64) -> NativeSink {
1777        let ability_id = uuid::Uuid::new_v4();
1778        let ability = Ability {
1779            template_id: ability_id,
1780            level: 1,
1781            shards_amount: 0,
1782        };
1783        let lookups = advance_lookups(ability_id, range);
1784        let mut entities = vec![caster.clone()];
1785        entities.extend(others);
1786        let fight = ActiveFight {
1787            entities,
1788            ..Default::default()
1789        };
1790        let config = configs::tests_game_config::generate_game_config_for_tests();
1791        let mut sink = NativeSink::default();
1792        advance_entity(&mut sink, &config, &lookups, &fight, caster, &ability).unwrap();
1793        sink
1794    }
1795
1796    /// The "run inside each other" fix: under full-gap the ally's probe from
1797    /// (0,1) toward the enemy at x=4 walks through x=2, but the enemy has
1798    /// reserved (2,1) via its `move_target`, so the probe must route around it
1799    /// (sidestep) and never land on the reserved cell.
1800    #[test]
1801    fn advance_entity_does_not_land_on_a_reserved_midpoint() {
1802        let caster = entity_at(EntityTeam::Ally, 0); // (0, 1)
1803        let mut enemy = entity_at_xy(EntityTeam::Enemy, 4, 1);
1804        enemy.move_target = Some(Coordinates { x: 2, y: 1 });
1805
1806        let sink = advance_in_fight(&caster, vec![enemy], 1);
1807        assert_eq!(sink.casts.len(), 1);
1808        assert_ne!(
1809            sink.casts[0].coordinates,
1810            Some(Coordinates { x: 2, y: 1 }),
1811            "must not land on the cell the enemy reserved for its run"
1812        );
1813    }
1814
1815    /// INV-5 + INV-1 (SCN-2/SCN-3): when two mobile entities plan in the same
1816    /// tick, the LIFO first planner reserves its full-gap destination before the
1817    /// second plans. The second planner must still MOVE (a real, non-zero run) —
1818    /// it must NOT stand — and must not land on or cross the reserved cell. Here
1819    /// the enemy at (6,1) has reserved (3,1) on the ally's forward lane; the
1820    /// ally at (0,1) probes forward, is blocked at the reserved (3,1), and
1821    /// sidesteps around it, emitting a real run that never sits on the
1822    /// reservation and never reaches the enemy's column.
1823    #[test]
1824    fn advance_entity_second_mid_meeter_runs_short_not_stand() {
1825        let caster = entity_at(EntityTeam::Ally, 0); // (0, 1)
1826        let mut enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
1827        enemy.move_target = Some(Coordinates { x: 3, y: 1 });
1828
1829        let sink = advance_in_fight(&caster, vec![enemy], 1);
1830        assert_eq!(sink.casts.len(), 1, "the second planner must emit one run");
1831        let dest = sink.casts[0].coordinates.clone().unwrap();
1832        // It MOVED (not a stand): destination differs from the start cell.
1833        assert_ne!(
1834            dest,
1835            Coordinates { x: 0, y: 1 },
1836            "second planner must move, not stand"
1837        );
1838        // It did not land on the reserved cell (no overlap with the reservation).
1839        assert_ne!(
1840            dest,
1841            Coordinates { x: 3, y: 1 },
1842            "must not land on the reserved cell"
1843        );
1844        // It stayed on its own side of the enemy (never onto/past x=6).
1845        assert!(
1846            dest.x < 6,
1847            "must not reach or cross the enemy's column (x=6)"
1848        );
1849    }
1850
1851    /// SCN-2 (even gap, no shared cell): with the enemy's full-gap destination
1852    /// reserved, the ally's emitted run must not collide with that reserved cell
1853    /// on an even gap (the classic half-gap same-midpoint overlap). Ally (0,1)
1854    /// vs enemy (6,1) reserving its own full-gap target (1,1) on the -x side.
1855    #[test]
1856    fn advance_entity_even_gap_no_shared_cell() {
1857        let caster = entity_at(EntityTeam::Ally, 0); // (0, 1)
1858        let mut enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
1859        // Enemy (moving -x) reserves its full-gap destination one cell short of
1860        // the ally: gap 6 → x = 6 - 5 = 1.
1861        enemy.move_target = Some(Coordinates { x: 1, y: 1 });
1862
1863        let sink = advance_in_fight(&caster, vec![enemy], 1);
1864        assert_eq!(sink.casts.len(), 1);
1865        let dest = sink.casts[0].coordinates.clone().unwrap();
1866        assert_ne!(
1867            dest,
1868            Coordinates { x: 1, y: 1 },
1869            "ally must not land on the enemy's reserved cell (no same-cell on even gaps)"
1870        );
1871    }
1872
1873    /// When every reachable forward cell is taken, the entity must emit NO run
1874    /// rather than overlap — even though a distant enemy means it "wants" to
1875    /// advance. Three same-team allies fill the whole forward column.
1876    #[test]
1877    fn advance_entity_emits_no_run_when_boxed_in() {
1878        let caster = entity_at(EntityTeam::Ally, 0); // (0, 1)
1879        let block_fwd = entity_at_xy(EntityTeam::Ally, 1, 1);
1880        let block_up = entity_at_xy(EntityTeam::Ally, 1, 2);
1881        let block_down = entity_at_xy(EntityTeam::Ally, 1, 0);
1882        let enemy = entity_at_xy(EntityTeam::Enemy, 4, 1); // far → wants to advance
1883
1884        let sink = advance_in_fight(&caster, vec![block_fwd, block_up, block_down, enemy], 1);
1885        assert!(
1886            sink.casts.is_empty(),
1887            "no free forward cell → no run (must not overlap)"
1888        );
1889    }
1890
1891    /// Symmetric-occupancy fix: an ally moving +x must now respect a cell that
1892    /// is already occupied (before the fix `width_offset = -direction*width`
1893    /// was <= 0 for allies, so the x-occupancy check was skipped entirely and
1894    /// allies walked straight onto occupied cells).
1895    #[test]
1896    fn advance_entity_ally_respects_occupied_forward_cell() {
1897        let caster = entity_at(EntityTeam::Ally, 0); // (0, 1)
1898        let blocker = entity_at_xy(EntityTeam::Ally, 1, 1); // sits on the forward cell
1899        let enemy = entity_at_xy(EntityTeam::Enemy, 2, 1); // gives gap 2
1900
1901        let sink = advance_in_fight(&caster, vec![blocker, enemy], 1);
1902        assert_eq!(sink.casts.len(), 1);
1903        assert_eq!(
1904            sink.casts[0].coordinates,
1905            Some(Coordinates { x: 1, y: 2 }),
1906            "must sidestep the occupied (1,1), not stack onto it"
1907        );
1908    }
1909
1910    /// Covering only half the gap toward a mobile opponent keeps a runner
1911    /// strictly on its own side — it can never cross to the other side and then
1912    /// (fixed-direction) march away forever, the "run past each other and run
1913    /// indefinitely" bug. Enemy at x=8 vs ally at x=1, gap 7 → enemy covers half
1914    /// (3 cells) to x=5, well short of the ally. Tests the enemy (−x) direction.
1915    #[test]
1916    fn advance_entity_half_gap_stays_on_its_own_side() {
1917        let caster = entity_at_xy(EntityTeam::Enemy, 8, 1);
1918        let mut ally = entity_at_xy(EntityTeam::Ally, 1, 1);
1919        // A *living* ally (hp>0) so the front-line clamp actually engages here:
1920        // the floor is x=2, and the half-gap destination (x=5) is well clear of
1921        // it — half-gap and the clamp coexist, the clamp does not bind.
1922        ally.hp = 100;
1923
1924        let sink = advance_in_fight(&caster, vec![ally], 1);
1925        assert_eq!(sink.casts.len(), 1);
1926        let dest = sink.casts[0].coordinates.clone().unwrap();
1927        assert_eq!(
1928            dest,
1929            Coordinates { x: 5, y: 1 },
1930            "enemy covers half the 7-cell gap (8 → 5)"
1931        );
1932        assert!(dest.x > 1, "must stay on its own side of the ally");
1933    }
1934
1935    /// An entity must never chase an opponent that is *behind* it (lower x for
1936    /// an ally). Movement is fixed-direction, so advancing toward a passed
1937    /// opponent would only widen the gap forever — the runaway the players saw.
1938    /// With no opponent ahead, no run is emitted.
1939    #[test]
1940    fn advance_entity_does_not_chase_an_opponent_behind_it() {
1941        let caster = entity_at_xy(EntityTeam::Ally, 5, 1); // ally already past
1942        let enemy = entity_at_xy(EntityTeam::Enemy, 2, 1); // enemy behind (lower x)
1943
1944        let sink = advance_in_fight(&caster, vec![enemy], 1);
1945        assert!(
1946            sink.casts.is_empty(),
1947            "ally must not advance further away from an opponent behind it"
1948        );
1949    }
1950
1951    /// Front-line clamp: an enemy may advance at most to the column directly in
1952    /// front of the frontmost living ally and no further. Enemy at x=6 vs a live
1953    /// static hero at x=1 → stops at x=2 (one column in front), never on/past the
1954    /// hero's column.
1955    #[test]
1956    fn advance_entity_enemy_stops_one_column_in_front_of_player() {
1957        let enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
1958        let mut hero = entity_at_xy(EntityTeam::Ally, 1, 1);
1959        hero.hp = 100; // a *living* ally defines the front line (hp>0 filter)
1960        hero.attributes.0.insert("static".to_string(), 1);
1961
1962        let sink = advance_in_fight(&enemy, vec![hero], 1);
1963        assert_eq!(sink.casts.len(), 1);
1964        assert_eq!(
1965            sink.casts[0].coordinates,
1966            Some(Coordinates { x: 2, y: 1 }),
1967            "enemy stops one column in front of the player (x=2), never on/past x=1"
1968        );
1969    }
1970
1971    /// The clamp's real bite: a living forward ally (pet/summon/party unit) sits
1972    /// at or ahead of the enemy's column on another row, so it is filtered out of
1973    /// the "nearest opponent ahead" check and the enemy would otherwise dive past
1974    /// it toward the backline hero — ending up *inside* the player's formation.
1975    /// The front-line floor (frontmost living ally x + 1) forbids that step.
1976    ///
1977    /// Enemy at (3,1); a live summon ahead at (5,0); the static hero back at
1978    /// (1,1). Without the clamp the enemy advances to x=2 (past the summon, next
1979    /// to the hero); with it the enemy may not go below x=6 and emits no run.
1980    #[test]
1981    fn advance_entity_enemy_does_not_dive_past_a_forward_ally() {
1982        let enemy = entity_at_xy(EntityTeam::Enemy, 3, 1);
1983        let mut summon = entity_at_xy(EntityTeam::Ally, 5, 0);
1984        summon.hp = 100;
1985        let mut hero = entity_at_xy(EntityTeam::Ally, 1, 1);
1986        hero.hp = 100;
1987        hero.attributes.0.insert("static".to_string(), 1);
1988
1989        let sink = advance_in_fight(&enemy, vec![summon, hero], 1);
1990        assert!(
1991            sink.casts.is_empty(),
1992            "enemy must not dive past the frontmost ally (x=5) into the formation"
1993        );
1994    }
1995
1996    /// The front line is the frontmost *living* ally: a dead forward ally must
1997    /// not hold enemies out at its column. Enemy at (3,1); a dead summon at (5,0)
1998    /// (hp 0); a live static hero at (1,1) → the line is the hero, so the enemy
1999    /// advances to x=2 (one column in front of the hero).
2000    #[test]
2001    fn advance_entity_front_line_ignores_dead_allies() {
2002        let enemy = entity_at_xy(EntityTeam::Enemy, 3, 1);
2003        let summon = entity_at_xy(EntityTeam::Ally, 5, 0); // hp 0 (default) → dead
2004        let mut hero = entity_at_xy(EntityTeam::Ally, 1, 1);
2005        hero.hp = 100;
2006        hero.attributes.0.insert("static".to_string(), 1);
2007
2008        let sink = advance_in_fight(&enemy, vec![summon, hero], 1);
2009        assert_eq!(sink.casts.len(), 1);
2010        assert_eq!(
2011            sink.casts[0].coordinates,
2012            Some(Coordinates { x: 2, y: 1 }),
2013            "dead forward ally is ignored; enemy stops one column in front of the live hero"
2014        );
2015    }
2016
2017    /// The clamp is symmetric: an ALLY advancing +x also stops one column in
2018    /// front of the opposing line. Ally at x=0 vs a live static enemy at x=6 →
2019    /// stops at x=5, never on/past the enemy's column.
2020    #[test]
2021    fn advance_entity_ally_stops_one_column_in_front_of_enemy() {
2022        let caster = entity_at_xy(EntityTeam::Ally, 0, 1);
2023        let mut enemy = entity_at_xy(EntityTeam::Enemy, 6, 1);
2024        enemy.hp = 100;
2025        enemy.attributes.0.insert("static".to_string(), 1);
2026
2027        let sink = advance_in_fight(&caster, vec![enemy], 1);
2028        assert_eq!(sink.casts.len(), 1);
2029        assert_eq!(
2030            sink.casts[0].coordinates,
2031            Some(Coordinates { x: 5, y: 1 }),
2032            "ally stops one column in front of the enemy (x=5), never on/past x=6"
2033        );
2034    }
2035
2036    /// Mutual approach must meet ONE COLUMN APART, not pile onto the same column
2037    /// (the "enemy right under the hero" overlap on an even gap). The clamp folds
2038    /// the opposing team's reserved `move_target`s into the front line, so the
2039    /// LIFO-second planner stops one column short of the first's reservation.
2040    /// Here the ally has already reserved a forward move to (3,1); the enemy
2041    /// planning second (gap 4, even) must stop at x=4, not share the ally's x=3.
2042    #[test]
2043    fn advance_entity_mutual_meet_keeps_one_column_gap_enemy_second() {
2044        let enemy = entity_at_xy(EntityTeam::Enemy, 5, 1);
2045        let mut ally = entity_at_xy(EntityTeam::Ally, 1, 1);
2046        ally.hp = 100;
2047        ally.move_target = Some(Coordinates { x: 3, y: 1 });
2048
2049        let sink = advance_in_fight(&enemy, vec![ally], 1);
2050        assert_eq!(sink.casts.len(), 1);
2051        let dest = sink.casts[0].coordinates.clone().unwrap();
2052        assert_eq!(
2053            dest.x, 4,
2054            "enemy must stop one column in front of the ally's reserved x=3, not on it"
2055        );
2056    }
2057
2058    /// Symmetric to the above: the ALLY planning second yields to the enemy's
2059    /// reservation. Enemy reserved a forward move to (3,1); the ally (gap 4) must
2060    /// stop at x=2, one column short of x=3.
2061    #[test]
2062    fn advance_entity_mutual_meet_keeps_one_column_gap_ally_second() {
2063        let ally = entity_at_xy(EntityTeam::Ally, 1, 1);
2064        let mut enemy = entity_at_xy(EntityTeam::Enemy, 5, 1);
2065        enemy.hp = 100;
2066        enemy.move_target = Some(Coordinates { x: 3, y: 1 });
2067
2068        let sink = advance_in_fight(&ally, vec![enemy], 1);
2069        assert_eq!(sink.casts.len(), 1);
2070        let dest = sink.casts[0].coordinates.clone().unwrap();
2071        assert_eq!(
2072            dest.x, 2,
2073            "ally must stop one column in front of the enemy's reserved x=3, not on it"
2074        );
2075    }
2076
2077    /// Diagonal sidesteps must take √2× a straight step — duration comes from
2078    /// the euclidean distance, not |Δx| (which made lane changes visibly fast).
2079    #[test]
2080    fn entity_run_duration_uses_euclidean_distance() {
2081        let entity = entity_at(EntityTeam::Ally, 0); // at (0, 1)
2082        let lookups = lookups_with_speed(10000.0);
2083
2084        let mut sink = NativeSink::default();
2085        entity_run(&mut sink, &lookups, &entity, Coordinates { x: 1, y: 2 }).unwrap();
2086
2087        assert_eq!(sink.casts.len(), 1);
2088        assert_eq!(
2089            sink.casts[0].run_duration_ticks,
2090            Some(707),
2091            "√2 cells × 500ms, floored"
2092        );
2093    }
2094
2095    /// TC-2: `get_entity_stat` floors the `.mod` multiplier at
2096    /// `MIN_STAT_MOD_MULT`, so a stacking debuff (weakness on attack, protection
2097    /// on received_damage) can't drive a stat to ≤0 (zero-damage / unkillable).
2098    #[test]
2099    fn get_entity_stat_mod_floor_prevents_zeroing() {
2100        let attack_id = uuid::Uuid::new_v4();
2101        let mut lookups = ContentLookups::default();
2102        lookups
2103            .attribute_by_code
2104            .insert("attack".to_string(), attack_id);
2105        lookups.attribute_base_value.insert(attack_id, 600.0);
2106
2107        // −500% `.mod` would be ×(−4) pre-floor → must clamp to ×0.05.
2108        let mut weak = Entity {
2109            id: uuid::Uuid::new_v4(),
2110            ..Default::default()
2111        };
2112        weak.attributes.add("attack.mod", -50_000);
2113        let v = get_entity_stat(&lookups, &weak, "attack");
2114        assert!(v > 0.0, "floored stat must stay positive, got {v}");
2115        assert!(
2116            (v - 600.0 * balance::MIN_STAT_MOD_MULT).abs() < 1e-9,
2117            "attack.mod must floor at MIN_STAT_MOD_MULT (×0.05), got {v}"
2118        );
2119
2120        // No mod → unaffected (base, mult 1.0).
2121        let neutral = Entity {
2122            id: uuid::Uuid::new_v4(),
2123            ..Default::default()
2124        };
2125        assert!((get_entity_stat(&lookups, &neutral, "attack") - 600.0).abs() < 1e-9);
2126    }
2127
2128    /// TC-6 / CM-2: `touch_enemy` dodge follows the DR curve `ev/(ev+K)` with the
2129    /// non-inverted comparison `roll ≥ dodge`, and asymptotes below 100% (no
2130    /// evasion cliff). Pins the direction so a future `>=`→`<` flip FAILS here.
2131    #[test]
2132    fn touch_enemy_dodge_direction_and_asymptote() {
2133        let k = balance::tuning().k_dodge;
2134        // evasion == K ⇒ dodge prob = 0.5. touched ⇔ roll ≥ 0.5.
2135        let mut target = Entity {
2136            id: uuid::Uuid::new_v4(),
2137            ..Default::default()
2138        };
2139        target.attributes.add("evasion", k as i64);
2140
2141        let rng = GameRng::from_values(vec![0.4]);
2142        assert!(
2143            !touch_enemy(&rng, &ContentLookups::default(), &target),
2144            "roll 0.4 < dodge 0.5 ⇒ dodged (not touched)"
2145        );
2146        let rng = GameRng::from_values(vec![0.6]);
2147        assert!(
2148            touch_enemy(&rng, &ContentLookups::default(), &target),
2149            "roll 0.6 ≥ dodge 0.5 ⇒ touched (direction must not be inverted)"
2150        );
2151
2152        // Asymptote < 100%: even enormous evasion can't guarantee a dodge — a
2153        // near-1 roll still lands. (dodge = 1e7/(1e7+K) ≈ 0.99985 < 1.)
2154        let mut glass = Entity {
2155            id: uuid::Uuid::new_v4(),
2156            ..Default::default()
2157        };
2158        glass.attributes.add("evasion", 10_000_000);
2159        let rng = GameRng::from_values(vec![0.999_999]);
2160        assert!(
2161            touch_enemy(&rng, &ContentLookups::default(), &glass),
2162            "dodge asymptotes below 100% — a 0.999999 roll still touches"
2163        );
2164
2165        // No evasion ⇒ always touched, no rng draw consumed (dummy value unused).
2166        let no_ev = Entity {
2167            id: uuid::Uuid::new_v4(),
2168            ..Default::default()
2169        };
2170        let rng = GameRng::from_values(vec![0.0]);
2171        assert!(
2172            touch_enemy(&rng, &ContentLookups::default(), &no_ev),
2173            "no evasion ⇒ always touched"
2174        );
2175    }
2176
2177    /// Regression guard for the "fight works but no damage" break AND the v2
2178    /// anti-unkillable floor. `received_damage` is a ×10000 multiplier whose base
2179    /// value (10000 == 100% taken) lives in `attribute_base_value`:
2180    /// * base lookup ENTIRELY ABSENT (content-extraction regression) →
2181    ///   `damage_entity` returns `None` (loud: fights visibly stall) — preserved
2182    ///   so the missing-base bug still surfaces.
2183    /// * base PRESENT but mitigated to ≤0 by a huge negative `received_damage.mod`
2184    ///   (e.g. stacked `protection`) → floored at `MIN_RECEIVED_DAMAGE_K`, so the
2185    ///   entity stays killable (`godmode` is the only true invuln).
2186    #[test]
2187    fn damage_requires_received_damage_base_value() {
2188        use rand::SeedableRng;
2189
2190        let target = Entity {
2191            id: uuid::Uuid::new_v4(),
2192            hp: 1000,
2193            max_hp: 1000,
2194            ..Default::default()
2195        };
2196        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
2197
2198        // Missing base: empty lookups → no received_damage base → all damage
2199        // cancelled (the loud content-regression guard).
2200        let empty = ContentLookups::default();
2201        let mut sink = NativeSink::default();
2202        let res = damage_entity(
2203            &mut sink,
2204            &rng,
2205            &empty,
2206            &target,
2207            100.0,
2208            CustomEventData::default(),
2209        )
2210        .unwrap();
2211        assert!(
2212            res.is_none(),
2213            "missing attribute_base_value cancels all damage (loud no-damage guard)"
2214        );
2215
2216        // Base present (10000 = 100% taken, sourced from config) → a hit lands.
2217        let rd_id = uuid::Uuid::new_v4();
2218        let mut lookups = ContentLookups::default();
2219        lookups
2220            .attribute_by_code
2221            .insert("received_damage".to_string(), rd_id);
2222        lookups.attribute_base_value.insert(rd_id, 10000.0);
2223
2224        let mut sink = NativeSink::default();
2225        let res = damage_entity(
2226            &mut sink,
2227            &rng,
2228            &lookups,
2229            &target,
2230            100.0,
2231            CustomEventData::default(),
2232        )
2233        .unwrap();
2234        assert!(
2235            res.is_some_and(|d| d > 0),
2236            "with received_damage base populated, a hit must deal damage"
2237        );
2238
2239        // Anti-unkillable (TC-1): base present but stacked `protection` drives
2240        // `received_damage.mod` hugely negative — the entity must STILL take
2241        // positive damage (floored), never become invulnerable.
2242        let mut tank = target.clone();
2243        tank.attributes.add("received_damage.mod", -50_000); // −500% ⇒ ≤0 pre-floor
2244        let mut sink = NativeSink::default();
2245        let res = damage_entity(
2246            &mut sink,
2247            &rng,
2248            &lookups,
2249            &tank,
2250            100.0,
2251            CustomEventData::default(),
2252        )
2253        .unwrap();
2254        assert!(
2255            res.is_some_and(|d| d > 0),
2256            "stacked protection must not make an entity unkillable (received_damage floor)"
2257        );
2258    }
2259
2260    /// Builds a minimal single-wave / single-spawn [`WaveFightData`] with the
2261    /// given reference `power`, runs [`spawn_wave`], and returns the `hp`
2262    /// attribute of the emitted `SpawnEntity` (the enemy that wave produced).
2263    fn spawn_wave_enemy_hp(power: Option<f64>) -> i64 {
2264        use rand::SeedableRng;
2265
2266        let enemy_id = uuid::Uuid::new_v4();
2267        let fight_data = WaveFightData {
2268            entities: vec![WaveEntityPower {
2269                entity_id: Some(enemy_id.to_string()),
2270                power: Some(1.0),
2271            }],
2272            waves: vec![vec![WaveSpawn {
2273                entity_id: enemy_id.to_string(),
2274                delay: Some(0.0),
2275                position: Some(Coordinates { x: 3, y: 3 }),
2276            }]],
2277            time: 1.0,
2278            power,
2279        };
2280
2281        // `spawn_wave` reads only `current_wave` + `entities` off the
2282        // fight; the fight kind is supplied separately via the `fight_type` arg.
2283        let fight = ActiveFight {
2284            current_wave: 1,
2285            ..Default::default()
2286        };
2287
2288        let config = configs::tests_game_config::generate_game_config_for_tests();
2289        let lookups = ContentLookups::default();
2290        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
2291        let mut sink = NativeSink::default();
2292
2293        spawn_wave(
2294            &mut sink,
2295            &rng,
2296            &config,
2297            &lookups,
2298            &fight,
2299            &fight_data,
2300            fight_data.power.unwrap_or(0.0),
2301            1,
2302            "CampaignFight",
2303        )
2304        .unwrap();
2305
2306        let spawn = sink
2307            .events
2308            .iter()
2309            .find_map(|ev| match ev {
2310                OverlordEvent::SpawnEntity {
2311                    entity_attributes, ..
2312                } => Some(entity_attributes),
2313                _ => None,
2314            })
2315            .expect("spawn_wave must emit a SpawnEntity for the current wave");
2316
2317        // `hp == 0` is stored as an absent key by `EntityAttributes::add`.
2318        spawn.0.get("hp").copied().unwrap_or(0)
2319    }
2320
2321    /// Regression guard for the "wave enemies spawn with 0 HP" bug: the typed
2322    /// config extraction dropped the per-fight reference `power`, so both
2323    /// `spawn_wave` call sites passed `base_power = 0.0`. With `base_power
2324    /// = 0`, `eff = 0` → `player_dps = 0` → `mob_hp_norm = 0` → every spawned
2325    /// enemy got `hp: 0` (instant death, no HP bar). Restoring a non-zero
2326    /// reference power must yield positive enemy HP.
2327    #[test]
2328    fn spawn_wave_enemy_hp_requires_nonzero_base_power() {
2329        // The bug: base_power 0 → zero player DPS → zero-HP enemies.
2330        assert_eq!(
2331            spawn_wave_enemy_hp(Some(0.0)),
2332            0,
2333            "base_power 0 must reproduce the zero-HP bug"
2334        );
2335
2336        // Fixed: a non-zero reference power yields a positive enemy HP / HP bar.
2337        assert!(
2338            spawn_wave_enemy_hp(Some(100.0)) > 0,
2339            "a non-zero reference power must produce wave enemies with positive HP"
2340        );
2341    }
2342
2343    /// Regression guard for over-long enemy transparency: a delayed wave spawn
2344    /// must set `wake_up_delay` exactly ONCE (= the configured spawn delay). The
2345    /// pre-migration code set it on the spawn attrs AND again via a post-spawn
2346    /// incr, doubling it so delayed enemies stayed asleep (transparent) ~2x as
2347    /// long as configured.
2348    #[test]
2349    fn delayed_spawn_sets_wake_up_delay_once() {
2350        use rand::SeedableRng;
2351
2352        let enemy_id = uuid::Uuid::new_v4();
2353        let delay = 5i64;
2354        let fight_data = WaveFightData {
2355            entities: vec![WaveEntityPower {
2356                entity_id: Some(enemy_id.to_string()),
2357                power: Some(1.0),
2358            }],
2359            waves: vec![vec![WaveSpawn {
2360                entity_id: enemy_id.to_string(),
2361                delay: Some(delay as f64),
2362                position: Some(Coordinates { x: 3, y: 3 }),
2363            }]],
2364            time: 1.0,
2365            power: Some(100.0),
2366        };
2367        let fight = ActiveFight {
2368            current_wave: 1,
2369            ..Default::default()
2370        };
2371        let config = configs::tests_game_config::generate_game_config_for_tests();
2372        let lookups = ContentLookups::default();
2373        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
2374        let mut sink = NativeSink::default();
2375        spawn_wave(
2376            &mut sink,
2377            &rng,
2378            &config,
2379            &lookups,
2380            &fight,
2381            &fight_data,
2382            100.0,
2383            1,
2384            "CampaignFight",
2385        )
2386        .unwrap();
2387
2388        // The spawned entity carries wake_up_delay == the configured delay.
2389        let attrs = sink
2390            .events
2391            .iter()
2392            .find_map(|ev| match ev {
2393                OverlordEvent::SpawnEntity {
2394                    entity_attributes, ..
2395                } => Some(entity_attributes),
2396                _ => None,
2397            })
2398            .expect("must emit a SpawnEntity");
2399        assert_eq!(attrs.0.get("wake_up_delay").copied(), Some(delay));
2400
2401        // No post-spawn IncrAttribute should re-add wake_up_delay (the 2x bug).
2402        let wake_incrs = sink
2403            .events
2404            .iter()
2405            .filter(|ev| {
2406                matches!(ev, OverlordEvent::EntityIncrAttribute { attribute, .. } if attribute == "wake_up_delay")
2407            })
2408            .count();
2409        assert_eq!(
2410            wake_incrs, 0,
2411            "wake_up_delay must not be incremented a second time (doubles sleep duration)"
2412        );
2413    }
2414}