overlord_event_system/behaviors/combat/
cast_ability.rs

1//! Native ports for the `cast_ability` category — ability `script`s (the
2//! `CastAbility` event handler, run via `run_event` in
3//! `logic::fighting::handle_cast_ability`, returning
4//! `Vec<OverlordEvent>`).
5//!
6//! These are the combat effect of *casting* an ability: most call
7//! `ctx.on_cast(CasterEntity)` then either launch a projectile
8//! (`Result.push(OverlordEventStartCastProjectile(...))`) or apply combat
9//! primitives (`ctx.attack`, `ctx.spell_heal`, `ctx.apply_entity_effect`).
10//!
11//! ## RNG
12//! Ability `script`s consume the authoritative `Random` (via `on_cast`'s
13//! `bravery` throw, `attack`'s evasion/counterattack/deceit/crit/block throws,
14//! and `stat_throw`).
15//!
16//! ## Effect callbacks
17//! `on_cast` / `apply_entity_effect` dispatch the applied effect's `on_apply`
18//! reaction. For the shipped effects those reactions push `add_entity_stat_mod`
19//! (`empower`/`weakness`/`protection`/`vulnerability`) events; the native path
20//! must replicate them, so we pass [`OverlordEffectCb`] rather than
21//! `NoopEffectCb`.
22//!
23//! ## Scope (per the `handle_cast_ability` `run_event` call site)
24//! `CasterEntity`, `TargetEntity`, `Fight`, `Random`, `AbilityLevel`,
25//! `AbilitySlotLevel`, `CurrentTick`, `FightDurationTicks`, and the event.
26
27use configs::game_config::GameConfig;
28use essences::combat_origin::CombatEventOrigin;
29use essences::entity::Entity;
30use essences::fight_breakdown::CombatSource;
31use essences::fighting::ActiveFight;
32use event_system::script::random::GameRng;
33use uuid::Uuid;
34
35use crate::behaviors::{BehaviorKind, BehaviorMeta, BehaviorRegistry};
36use crate::event::OverlordEvent;
37use crate::game_config_helpers::GameConfigLookup;
38use crate::mechanics::content::ability_info;
39use crate::mechanics::content_lookups::ContentLookups;
40use crate::mechanics::effect_cb::OverlordEffectCb;
41use crate::mechanics::fight::{
42    self, AttackParams, NativeSink, SpellHealParams, on_cast, spell_heal, stat_throw,
43};
44use crate::mechanics::support_shapes::{SupportAttackCtx, attack_with_supports};
45use essences::ability_stones::AbilityStoneMods;
46
47/// ability `script` scope the shipped scripts read.
48pub struct CastAbilityCtx<'a> {
49    /// `CasterEntity` const.
50    pub caster_entity: &'a Entity,
51    /// `TargetEntity` const.
52    pub target_entity: &'a Entity,
53    /// `Fight` const (used by the cone/AoE ability for `Fight.entities`).
54    pub fight: &'a ActiveFight,
55    /// RNG snapshot (clone at the same state) so combat primitives draw
56    pub rng: &'a GameRng,
57    /// `AbilityLevel` const (passed to `get_ability_info` and projectile launches).
58    pub ability_level: i64,
59    /// The ability actually being cast. Ports that hardcode a donor ability id
60    /// (pet-ult clones) still read their own numbers; this is what the socketed
61    /// support stones are keyed by.
62    pub ability_id: uuid::Uuid,
63    pub config: &'a GameConfig,
64    pub lookups: &'a ContentLookups,
65    /// Ability stones active on the caster, already resolved against the
66    /// current world side and the caster's mana. Identity for every combatant
67    /// without socketed stones.
68    pub stones: crate::mechanics::ability_stones::AbilityStoneResolver<'a>,
69    /// Breakdown attribution of everything this cast produces: `Ability` for an
70    /// ordinary cast, `PetUlt` when the charge-driven pet ult is what fired it.
71    /// Set by the dispatcher, which is the only place that knows the difference.
72    pub source: CombatSource,
73}
74
75/// Signature of a `cast_ability` native fn.
76pub type CastAbilityFn = fn(&CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>>;
77
78/// Run `ctx.on_cast(CasterEntity)` into a sink — the `on_cast` hook (bravery
79/// throw → maybe apply protection/empower, dispatching the effect `on_apply`).
80fn run_on_cast(ctx: &CastAbilityCtx, sink: &mut NativeSink) -> anyhow::Result<()> {
81    let mut effects = OverlordEffectCb;
82    on_cast(sink, ctx.rng, ctx.lookups, &mut effects, ctx.caster_entity)
83        .map_err(|e| anyhow::anyhow!("on_cast: {e}"))
84}
85
86/// Run `ctx.attack(caster, target, params)` into a sink through the support
87/// seam (crit bonus + Split/Chain/Pierce copies + Leech), returning the floored
88/// damage of the PRIMARY hit.
89fn run_attack(
90    ctx: &CastAbilityCtx,
91    sink: &mut NativeSink,
92    mods: &AbilityStoneMods,
93    target: &Entity,
94    params: &AttackParams,
95) -> anyhow::Result<Option<i64>> {
96    attack_with_supports(
97        sink,
98        &SupportAttackCtx {
99            rng: ctx.rng,
100            lookups: ctx.lookups,
101            fight: ctx.fight,
102            player_id: ctx.fight.player_id,
103            caster: ctx.caster_entity,
104            mods,
105            source: ctx.source,
106        },
107        target,
108        params,
109    )
110}
111
112/// `OverlordEventStartCastProjectile(caster.id, target.id, uuid(pid), level, delay)`).
113fn start_cast_projectile(ctx: &CastAbilityCtx, projectile_id: &str, delay: u64) -> OverlordEvent {
114    OverlordEvent::StartCastProjectile {
115        by_entity_id: ctx.caster_entity.id,
116        to_entity_id: ctx.target_entity.id,
117        projectile_id: Uuid::parse_str(projectile_id).expect("valid projectile uuid literal"),
118        level: ctx.ability_level,
119        delay,
120        origin: CombatEventOrigin::Core,
121        source: ctx.source,
122    }
123}
124
125/// `content::get_ability_info(id, AbilityLevel)` — resolves the per-ability info
126/// closure for the given ability id at the current level, plus the ability-stone
127/// modifiers that apply to it (already folded into the returned info).
128fn info(
129    ctx: &CastAbilityCtx,
130    ability_id: &str,
131) -> anyhow::Result<(crate::mechanics::content::AbilityInfo, AbilityStoneMods)> {
132    let id = Uuid::parse_str(ability_id).expect("valid ability uuid literal");
133    let mut info = ability_info(ctx.config, ctx.lookups, id, ctx.ability_level)?;
134    let mods = ctx.stones.mods_for(ctx.config, id, ctx.ability_level);
135    info.apply_stone_mods(&mods);
136    Ok((info, mods))
137}
138
139/// §1 AoE cap: the band-eligible enemies of a forward 3-cell cone (`caster_x <
140/// x ≤ caster_x+3`), capped to the first `max_targets` in the deterministic
141/// `fight.entities` iteration order (`None` ⇒ unlimited, legacy behaviour). No
142/// RNG, no sorting. Shared by `cone_strike` / Rewind / War Cry / Holy Nova so
143/// the cap is byte-identical everywhere.
144pub fn band_targets<'a>(
145    fight: &'a ActiveFight,
146    caster: &Entity,
147    max_targets: Option<i64>,
148) -> Vec<&'a Entity> {
149    let caster_x = caster.coordinates.x;
150    let mut out: Vec<&Entity> = Vec::new();
151    for target in fight.entities.iter().filter(|e| e.team != caster.team) {
152        let tx = target.coordinates.x;
153        if tx > caster_x && tx <= caster_x + 3 {
154            if max_targets.is_some_and(|cap| out.len() as i64 >= cap) {
155                break;
156            }
157            out.push(target);
158        }
159    }
160    out
161}
162
163// ---------------------------------------------------------------------------
164// Shared parametrized bodies
165// ---------------------------------------------------------------------------
166
167/// `on_cast` + push a single `StartCastProjectile(projectile_id, delay=0)`.
168/// Covers every "on_cast then launch one projectile" ability.
169fn on_cast_then_projectile(
170    ctx: &CastAbilityCtx,
171    projectile_id: &str,
172) -> anyhow::Result<Vec<OverlordEvent>> {
173    let mut sink = NativeSink::default();
174    run_on_cast(ctx, &mut sink)?;
175    sink.events
176        .push(start_cast_projectile(ctx, projectile_id, 0));
177    Ok(sink.events)
178}
179
180/// Bare `Result.push(StartCastProjectile(projectile_id, delay=0))` — no
181/// `fight_context`, no `on_cast` (the four scripts that are a single push line).
182fn bare_projectile(
183    ctx: &CastAbilityCtx,
184    projectile_id: &str,
185) -> anyhow::Result<Vec<OverlordEvent>> {
186    Ok(vec![start_cast_projectile(ctx, projectile_id, 0)])
187}
188
189/// `on_cast` + `attack(caster, target, #{ power: ability_info.damage })`.
190fn on_cast_then_attack(
191    ctx: &CastAbilityCtx,
192    ability_id: &str,
193) -> anyhow::Result<Vec<OverlordEvent>> {
194    let mut sink = NativeSink::default();
195    run_on_cast(ctx, &mut sink)?;
196    let (ability_info, mods) = info(ctx, ability_id)?;
197    let params = AttackParams {
198        power: ability_info.damage,
199        ..Default::default()
200    };
201    run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
202    Ok(sink.events)
203}
204
205// ---------------------------------------------------------------------------
206// Distinct ability ports
207// ---------------------------------------------------------------------------
208
209/// `0194d64e-...` — on_cast + attack { power }.
210pub fn melee_strike(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
211    on_cast_then_attack(ctx, "0194d64e-20f2-75e5-89c8-4cb812672485")
212}
213
214/// `019bff40-...` — on_cast + attack { power }.
215pub fn sword_slash(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
216    on_cast_then_attack(ctx, "019bff40-af44-75b7-940c-6074097a2925")
217}
218
219/// `019cc464-...` — on_cast + attack { power }.
220pub fn mob_melee_quick(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
221    on_cast_then_attack(ctx, "019cc464-e752-71c1-a9dd-8fda9f212801")
222}
223
224/// `019cc465-14b8-...` — on_cast + attack { power }.
225pub fn mob_melee_average(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
226    on_cast_then_attack(ctx, "019cc465-14b8-7dbc-9799-4691b91805d3")
227}
228
229/// `019cc465-2f63-...` — on_cast + attack { power }.
230pub fn mob_melee_slow(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
231    on_cast_then_attack(ctx, "019cc465-2f63-7b54-8ddc-fcbcb483fe81")
232}
233
234/// `01955be6-...` — `ctx.apply_entity_effect(CasterEntity, "test_effect", 3)`.
235/// No `on_cast`. `test_effect`'s `on_apply` is `print`-only (no events).
236pub fn apply_test_effect(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
237    let mut sink = NativeSink::default();
238    let mut effects = OverlordEffectCb;
239    fight::apply_entity_effect(
240        &mut sink,
241        ctx.lookups,
242        &mut effects,
243        ctx.caster_entity,
244        "test_effect",
245        3.0,
246    )
247    .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
248    Ok(sink.events)
249}
250
251/// `019584aa-...` — on_cast + attack { power, crit_chance_bonus * 10000.0 }.
252pub fn crit_strike(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
253    let mut sink = NativeSink::default();
254    run_on_cast(ctx, &mut sink)?;
255    let (ability_info, mods) = info(ctx, "019584aa-5bde-7ac2-8850-076dafdc4603")?;
256    let params = AttackParams {
257        power: ability_info.damage,
258        crit_chance_bonus: ability_info.crit_chance_bonus.unwrap_or(0.0) * 10000.0,
259        ..Default::default()
260    };
261    run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
262    Ok(sink.events)
263}
264
265/// `019584f4-...` — cone/AoE: on_cast, roll one shared crit, then attack every
266/// enemy in a forward 3-cell x-band with `{ power, is_crit }`.
267pub fn cone_strike(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
268    let mut sink = NativeSink::default();
269    run_on_cast(ctx, &mut sink)?;
270    let (ability_info, mods) = info(ctx, "019584f4-2c99-72bf-bcd3-bfc02fb33977")?;
271    // `let is_crit = ctx.stat_throw(CasterEntity, "crit_chance");` — one draw.
272    let is_crit = stat_throw(ctx.rng, ctx.lookups, ctx.caster_entity, "crit_chance", 0.0);
273    // `CasterEntity.get_enemies(Fight.entities)` preserves `Fight.entities` order,
274    // capped by §1 `max_targets`.
275    for target in band_targets(ctx.fight, ctx.caster_entity, ability_info.max_targets) {
276        let params = AttackParams {
277            power: ability_info.damage,
278            is_crit: Some(is_crit),
279            ..Default::default()
280        };
281        run_attack(ctx, &mut sink, &mods, target, &params)?;
282    }
283    Ok(sink.events)
284}
285
286/// `019589e6-...` — on_cast + launch `ability_info.projectiles` projectiles,
287/// each delayed `i * 150`.
288pub fn multi_projectile(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
289    const PROJECTILE_DELAY: i64 = 150;
290    let mut sink = NativeSink::default();
291    run_on_cast(ctx, &mut sink)?;
292    let (ability_info, _mods) = info(ctx, "019589e6-f9dd-7b22-8d39-5350e95aaf69")?;
293    let projectiles = ability_info.projectiles.unwrap_or(0);
294    for i in 0..projectiles {
295        let delay = (i * PROJECTILE_DELAY) as u64;
296        sink.events.push(start_cast_projectile(
297            ctx,
298            "0196a6b3-f885-7fdc-af8c-92a1ffb79ceb",
299            delay,
300        ));
301    }
302    Ok(sink.events)
303}
304
305/// `01958a30-...` — on_cast + attack { power }; if damage dealt (`!= ()`),
306/// apply `empower` for `ability_info.effect_duration`.
307pub fn strike_then_empower(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
308    let mut sink = NativeSink::default();
309    run_on_cast(ctx, &mut sink)?;
310    let (ability_info, mods) = info(ctx, "01958a30-8f18-745f-928a-75028cb3ee99")?;
311    let params = AttackParams {
312        power: ability_info.damage,
313        ..Default::default()
314    };
315    let damage = run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
316    if damage.is_some() {
317        let mut effects = OverlordEffectCb;
318        fight::apply_entity_effect(
319            &mut sink,
320            ctx.lookups,
321            &mut effects,
322            ctx.caster_entity,
323            "empower",
324            ability_info.effect_duration.unwrap_or(0.0),
325        )
326        .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
327    }
328    Ok(sink.events)
329}
330
331/// `01958ef2-...` — on_cast + attack { power }; if damage dealt, apply
332/// `vulnerability` on the TARGET for `ability_info.effect_duration`.
333pub fn strike_then_vulnerability(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
334    let mut sink = NativeSink::default();
335    run_on_cast(ctx, &mut sink)?;
336    let (ability_info, mods) = info(ctx, "01958ef2-dff5-76dd-89f1-d9c2707b2ffc")?;
337    let params = AttackParams {
338        power: ability_info.damage,
339        ..Default::default()
340    };
341    let damage = run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
342    if damage.is_some() {
343        let mut effects = OverlordEffectCb;
344        fight::apply_entity_effect(
345            &mut sink,
346            ctx.lookups,
347            &mut effects,
348            ctx.target_entity,
349            "vulnerability",
350            ability_info.effect_duration.unwrap_or(0.0),
351        )
352        .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
353    }
354    Ok(sink.events)
355}
356
357/// Pet-ult deliveries: same combat math as the instant versions
358/// (`crit_strike` / `cone_strike` / `dot_strike`), moved onto a projectile so
359/// the pet's cast is readable in combat. The damage resolves in the paired
360/// `projectile_pet_*` fn on arrival.
361pub fn night_pounce_projectile_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
362    on_cast_then_projectile(ctx, "79f90288-9ff4-46ee-9b11-8a0064913415")
363}
364
365pub fn rust_wave_projectile_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
366    on_cast_then_projectile(ctx, "1ce4597d-dd7a-4c5c-834b-134ef532c0c2")
367}
368
369pub fn odd_flame_projectile_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
370    on_cast_then_projectile(ctx, "432f231c-75eb-4753-acca-b6356ca8104c")
371}
372
373/// `01958efd-...` — on_cast + attack { dot_power: ability_info.damage } (no
374/// instant power; pure DoT application).
375pub fn dot_strike(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
376    let mut sink = NativeSink::default();
377    run_on_cast(ctx, &mut sink)?;
378    let (ability_info, mods) = info(ctx, "01958efd-77f9-7dec-8444-c9d759549225")?;
379    let params = AttackParams {
380        dot_power: ability_info.damage,
381        ..Default::default()
382    };
383    run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
384    Ok(sink.events)
385}
386
387/// `01958ed0-...` — on_cast + `spell_heal(caster, caster, #{ hot_power })`.
388pub fn self_hot_heal(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
389    let mut sink = NativeSink::default();
390    run_on_cast(ctx, &mut sink)?;
391    let (ability_info, _mods) = info(ctx, "01958ed0-d45f-7cad-b086-8f11962d3859")?;
392    let params = SpellHealParams {
393        hot_power: ability_info.hot,
394        ..Default::default()
395    };
396    spell_heal(
397        &mut sink,
398        ctx.rng,
399        ctx.lookups,
400        ctx.caster_entity,
401        ctx.caster_entity,
402        &params,
403        ctx.source,
404    )
405    .map_err(|e| anyhow::anyhow!("spell_heal: {e}"))?;
406    Ok(sink.events)
407}
408
409// ---------------------------------------------------------------------------
410// Class kits — long-cooldown signature casts composed
411// from the existing combat vocabulary (attack / spell_heal / effects), one fn
412// per ability so each class plays differently. Budgets come from the matching
413// `content::ability_info` closures.
414// ---------------------------------------------------------------------------
415
416/// Heal `share` of the RECIPIENT's own Max HP (BAL-033).
417///
418/// Class heals are authored as a share of the pool they are protecting, not as
419/// a multiple of the caster's ATK — a Priest healing a tankier ally heals more,
420/// and a caster with no gear still heals a meaningful amount. No crit roll:
421/// the printed number is the number.
422fn heal_max_hp_share(
423    sink: &mut NativeSink,
424    by_entity_id: Option<essences::entity::EntityId>,
425    recipient: &essences::entity::Entity,
426    share: f64,
427    source: CombatSource,
428) -> anyhow::Result<()> {
429    if share <= 0.0 || recipient.max_hp == 0 {
430        return Ok(());
431    }
432    fight::heal_entity(
433        sink,
434        by_entity_id,
435        recipient,
436        recipient.max_hp as f64 * share,
437        source,
438    )
439    .map_err(|e| anyhow::anyhow!("heal_entity: {e}"))?;
440    Ok(())
441}
442
443/// The ally with the lowest share of its Max HP, or the caster when nothing
444/// else qualifies. Keyed on the SHARE rather than the raw number so a big ally
445/// at half health outranks a small one a few points down.
446fn lowest_hp_share_ally<'a>(
447    fight: &'a essences::fighting::ActiveFight,
448    caster: &'a essences::entity::Entity,
449) -> &'a essences::entity::Entity {
450    fight
451        .entities
452        .iter()
453        .filter(|entity| entity.team == caster.team && entity.hp > 0 && entity.max_hp > 0)
454        .min_by(|a, b| {
455            let share = |e: &essences::entity::Entity| e.hp as f64 / e.max_hp as f64;
456            share(a)
457                .partial_cmp(&share(b))
458                .unwrap_or(std::cmp::Ordering::Equal)
459        })
460        .unwrap_or(caster)
461}
462
463/// Backstab (Rogue, `019dfc4c`): heavy crit gamble — like `crit_strike` but
464/// with the kit's own budget/crit split.
465pub fn backstab_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
466    /// Below this share of HP the target is finishable (BAL-033).
467    const EXECUTE_HP_SHARE: f64 = 0.30;
468    /// What the strike is multiplied by once the target is that low.
469    const EXECUTE_MULT: f64 = 1.70;
470
471    let mut sink = NativeSink::default();
472    run_on_cast(ctx, &mut sink)?;
473    let (ability_info, mods) = info(ctx, "019dfc4c-75ea-716c-a453-801c968be604")?;
474
475    // The kit's identity is a GUARANTEED crit, not a crit gamble: the rogue
476    // knows the hit lands hard, and the build plans around it.
477    let target = ctx.target_entity;
478    let wounded = target.max_hp > 0 && (target.hp as f64 / target.max_hp as f64) < EXECUTE_HP_SHARE;
479    let power = ability_info.damage.map(|damage| {
480        if wounded {
481            damage * EXECUTE_MULT
482        } else {
483            damage
484        }
485    });
486
487    let params = AttackParams {
488        power,
489        is_crit: Some(true),
490        ..Default::default()
491    };
492    run_attack(ctx, &mut sink, &mods, target, &params)?;
493    Ok(sink.events)
494}
495
496/// Thousand Cuts (Rogue, `019dfc4f`): a flurry of `projectiles` direct cuts on
497/// the target — each rolls its own crit/evasion, so the flurry FEELS like a
498/// storm of numbers rather than one hit.
499pub fn thousand_cuts_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
500    let mut sink = NativeSink::default();
501    run_on_cast(ctx, &mut sink)?;
502    let (ability_info, mods) = info(ctx, "019dfc4f-5c4f-7829-affa-11b21a735f78")?;
503    let cuts = ability_info.projectiles.unwrap_or(1).max(1);
504    for i in 0..cuts {
505        let params = AttackParams {
506            power: ability_info.damage,
507            // Only the first cut can trigger the target's counterattack —
508            // a 10-hit flurry must not roll ten counter procs.
509            no_counterattack: i > 0,
510            ..Default::default()
511        };
512        run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
513    }
514    Ok(sink.events)
515}
516
517/// Arcane Blast (Mage, `019dfcfb`): the nuke lands on the target and washes
518/// over every OTHER living enemy at a share of it — with no target cap
519/// (BAL-034), which is what makes the Mage the wave-clear class rather than a
520/// single-target one.
521pub fn arcane_blast_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
522    /// The secondary's share of the primary hit (`200%` against `500%`).
523    const SECONDARY_SHARE: f64 = 0.4;
524
525    let mut sink = NativeSink::default();
526    run_on_cast(ctx, &mut sink)?;
527    let (ability_info, mods) = info(ctx, "019dfcfb-7b13-7cf2-b8e3-ab9546310c2b")?;
528
529    let primary = AttackParams {
530        power: ability_info.damage,
531        ..Default::default()
532    };
533    run_attack(ctx, &mut sink, &mods, ctx.target_entity, &primary)?;
534
535    // Every other enemy still standing, in deterministic fight order. `None`
536    // as the cap is the whole point: the secondary is uncapped.
537    let secondary_power = ability_info.damage.map(|damage| damage * SECONDARY_SHARE);
538    for target in band_targets(ctx.fight, ctx.caster_entity, None) {
539        if target.id == ctx.target_entity.id {
540            continue;
541        }
542        let params = AttackParams {
543            power: secondary_power,
544            ..Default::default()
545        };
546        run_attack(ctx, &mut sink, &mods, target, &params)?;
547    }
548    Ok(sink.events)
549}
550
551/// Rewind (Mage, `019dfcfe`): AoE hit + Weakness (attack down) on every enemy
552/// struck — «время тянет волну назад».
553/// How much of every OTHER skill's remaining cooldown Rewind erases.
554const REWIND_COOLDOWN_CUT_TICKS: i64 = 2_000;
555
556/// Rewind's own template id — it is excluded from its own cooldown cut.
557const REWIND_ABILITY_ID: uuid::Uuid = uuid::uuid!("019dfcfe-704c-73cf-b6e8-60f85e86799d");
558
559pub fn rewind_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
560    let mut sink = NativeSink::default();
561    run_on_cast(ctx, &mut sink)?;
562    let (ability_info, mods) = info(ctx, "019dfcfe-704c-73cf-b6e8-60f85e86799d")?;
563    let is_crit = stat_throw(ctx.rng, ctx.lookups, ctx.caster_entity, "crit_chance", 0.0);
564    let duration = ability_info.effect_duration.unwrap_or(0.0);
565    for target in band_targets(ctx.fight, ctx.caster_entity, ability_info.max_targets) {
566        let params = AttackParams {
567            power: ability_info.damage,
568            is_crit: Some(is_crit),
569            ..Default::default()
570        };
571        let dealt = run_attack(ctx, &mut sink, &mods, target, &params)?;
572        if dealt.is_some() && duration > 0.0 {
573            let mut effects = OverlordEffectCb;
574            fight::apply_entity_effect(
575                &mut sink,
576                ctx.lookups,
577                &mut effects,
578                target,
579                "weakness",
580                duration,
581            )
582            .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
583        }
584    }
585
586    // BAL-034: the rewind also pulls the caster's OTHER skills forward. Never
587    // its own — a self-shortening Rewind would loop on itself, which is why
588    // the exclusion is part of the ability rather than a tuning value.
589    for ability in &ctx.caster_entity.abilities {
590        if ability.ability.template_id == REWIND_ABILITY_ID {
591            continue;
592        }
593        sink.events.push(OverlordEvent::EntityAddAbilityCooldown {
594            entity_id: ctx.caster_entity.id,
595            ability_id: ability.ability.template_id,
596            delta_ticks: -REWIND_COOLDOWN_CUT_TICKS,
597        });
598    }
599    Ok(sink.events)
600}
601
602/// Fortify (Warrior, `019dfd00`): Protection on self (received damage halved)
603/// plus an instant self-heal from the rest of the budget.
604pub fn fortify_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
605    let mut sink = NativeSink::default();
606    run_on_cast(ctx, &mut sink)?;
607    let (ability_info, _mods) = info(ctx, "019dfd00-594e-755f-8132-1c320fb2b5e9")?;
608    if let Some(duration) = ability_info.effect_duration.filter(|d| *d > 0.0) {
609        let mut effects = OverlordEffectCb;
610        fight::apply_entity_effect(
611            &mut sink,
612            ctx.lookups,
613            &mut effects,
614            ctx.caster_entity,
615            "protection",
616            duration,
617        )
618        .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
619    }
620    // `hot` carries the Max-HP SHARE for class heals, not an ATK multiplier.
621    heal_max_hp_share(
622        &mut sink,
623        Some(ctx.caster_entity.id),
624        ctx.caster_entity,
625        ability_info.hot.unwrap_or(0.0),
626        ctx.source,
627    )?;
628    Ok(sink.events)
629}
630
631/// War Cry (Warrior, `019dfd01`): AoE shout + `+30%` outgoing damage on self
632/// while any enemy was struck.
633pub fn war_cry_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
634    let mut sink = NativeSink::default();
635    run_on_cast(ctx, &mut sink)?;
636    let (ability_info, mods) = info(ctx, "019dfd01-073e-7aee-a993-74e20b3c439c")?;
637    let is_crit = stat_throw(ctx.rng, ctx.lookups, ctx.caster_entity, "crit_chance", 0.0);
638    let mut any_hit = false;
639    for target in band_targets(ctx.fight, ctx.caster_entity, ability_info.max_targets) {
640        let params = AttackParams {
641            power: ability_info.damage,
642            is_crit: Some(is_crit),
643            ..Default::default()
644        };
645        any_hit |= run_attack(ctx, &mut sink, &mods, target, &params)?.is_some();
646    }
647    if any_hit && let Some(duration) = ability_info.effect_duration.filter(|d| *d > 0.0) {
648        let mut effects = OverlordEffectCb;
649        fight::apply_entity_effect(
650            &mut sink,
651            ctx.lookups,
652            &mut effects,
653            ctx.caster_entity,
654            "war_fury",
655            duration,
656        )
657        .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
658    }
659    Ok(sink.events)
660}
661
662/// Battle Heal (Priest, `019dfd02-0c6a`): the WHOLE TEAM is healed and
663/// empowered (BAL-033). In solo that is the Priest alone, so the ability never
664/// reads as weaker than it did — it just stops being self-only the moment
665/// there is somebody to heal.
666pub fn battle_heal_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
667    let mut sink = NativeSink::default();
668    run_on_cast(ctx, &mut sink)?;
669    let (ability_info, _mods) = info(ctx, "019dfd02-0c6a-7f4d-bb45-4ec2a5fc231a")?;
670
671    let allies: Vec<_> = ctx
672        .fight
673        .entities
674        .iter()
675        .filter(|entity| entity.team == ctx.caster_entity.team && entity.hp > 0)
676        .cloned()
677        .collect();
678
679    for ally in &allies {
680        // Each recipient's heal is a share of ITS OWN Max HP (BAL-033).
681        heal_max_hp_share(
682            &mut sink,
683            Some(ctx.caster_entity.id),
684            ally,
685            ability_info.hot.unwrap_or(0.0),
686            ctx.source,
687        )?;
688
689        // The buff half of the ability: `+15% ATK` and `+15% Armor`.
690        // Re-casting refreshes it rather than stacking a second copy — one
691        // source, one window.
692        if let Some(duration) = ability_info.effect_duration.filter(|d| *d > 0.0) {
693            let mut effects = OverlordEffectCb;
694            fight::apply_entity_effect(
695                &mut sink,
696                ctx.lookups,
697                &mut effects,
698                ally,
699                "battle_blessing",
700                duration,
701            )
702            .map_err(|e| anyhow::anyhow!("apply_entity_effect: {e}"))?;
703        }
704    }
705    Ok(sink.events)
706}
707
708/// Holy Nova (Priest, `019dfd02-add4`): AoE smite plus exactly ONE heal for a
709/// share of the recipient's Max HP — the ally with the lowest HP share, which
710/// in solo is the caster.
711pub fn holy_nova_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
712    let mut sink = NativeSink::default();
713    run_on_cast(ctx, &mut sink)?;
714    let (ability_info, mods) = info(ctx, "019dfd02-add4-7373-912c-8483150fd341")?;
715    let is_crit = stat_throw(ctx.rng, ctx.lookups, ctx.caster_entity, "crit_chance", 0.0);
716    for target in band_targets(ctx.fight, ctx.caster_entity, ability_info.max_targets) {
717        let params = AttackParams {
718            power: ability_info.damage,
719            is_crit: Some(is_crit),
720            ..Default::default()
721        };
722        run_attack(ctx, &mut sink, &mods, target, &params)?;
723    }
724    // BAL-033: exactly ONE heal event, on the ally with the lowest HP share —
725    // in solo that resolves to the caster. The amount is a share of THAT
726    // entity's Max HP, not of the damage the nova dealt.
727    let recipient = lowest_hp_share_ally(ctx.fight, ctx.caster_entity).clone();
728    heal_max_hp_share(
729        &mut sink,
730        Some(ctx.caster_entity.id),
731        &recipient,
732        ability_info.hot.unwrap_or(0.0),
733        ctx.source,
734    )?;
735    Ok(sink.events)
736}
737
738// --- on_cast + single projectile (projectile id 01966cbc, AbilityLevel) ---
739
740/// `01958172-...` — on_cast + launch projectile `01966cbc`.
741pub fn fireball_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
742    on_cast_then_projectile(ctx, "01966cbc-879d-7b00-b1de-ad8ed932fb63")
743}
744
745/// `019dfc4c-...` / `019dfc4f` / `019dfcfb` / `019dfcfe` / `019dfd00` /
746/// `019dfd01` / `019dfd02-0c6a` / `019dfd02-add4` — all identical: on_cast +
747/// launch projectile `01966cbc`. (One shared native fn; config points all eight
748/// ability `script_native` refs at it.)
749pub fn fireball_projectile_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
750    on_cast_then_projectile(ctx, "01966cbc-879d-7b00-b1de-ad8ed932fb63")
751}
752
753/// `019589f7-...` — on_cast + launch projectile `0196a6b4` (vampiric strike).
754pub fn vampiric_touch_cast(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
755    on_cast_then_projectile(ctx, "0196a6b4-cb8c-7bcb-a99c-1f0189dd8d5f")
756}
757
758// --- bare projectile push (no fight_context, no on_cast) ---
759
760/// `019a0245-ff0c-...` — bare push projectile `019a0244-4675`.
761pub fn bare_projectile_mushroom(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
762    bare_projectile(ctx, "019a0244-4675-7e4b-868a-c9b8ef46a091")
763}
764
765/// `019a0246-5aaf-...` — bare push projectile `019a0244-7a0a`.
766pub fn bare_projectile_chipmunk(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
767    bare_projectile(ctx, "019a0244-7a0a-7241-bdae-4541d9f972f6")
768}
769
770/// `019a0246-cf87-...` — bare push projectile `019a0244-aad4`.
771pub fn bare_projectile_bat(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
772    bare_projectile(ctx, "019a0244-aad4-7ae3-ae11-25d5facadf17")
773}
774
775/// `019c00a4-...` — bare push projectile `019c009c`.
776pub fn bare_projectile_shot(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
777    bare_projectile(ctx, "019c009c-22b2-7fa7-9599-97eb239b13b9")
778}
779
780// ---------------------------------------------------------------------------
781// Test-config ability ports (tests_game_config.rs fixtures)
782// ---------------------------------------------------------------------------
783
784/// Port of the shared test ability `script` (`generate_ability_script`):
785/// `Result.push(OverlordEventDamage(TargetEntity.id, unsigned(5), CustomEventData()));`
786/// RNG-free; used by the test-config gacha/class abilities.
787pub fn damage_target_5(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
788    Ok(vec![OverlordEvent::Damage {
789        by_entity_id: Some(ctx.caster_entity.id),
790        entity_id: ctx.target_entity.id,
791        damage: 5,
792        damage_data: crate::event::CustomEventData::default(),
793        origin: CombatEventOrigin::Core,
794        source: ctx.source,
795    }])
796}
797
798/// A real swing for the test config: runs `mechanics::fight::attack` with a
799/// fixed `power` and a forced non-crit.
800///
801/// Every other fixture ability emits a flat `Damage` event and so never touches
802/// the damage primitives. That left the whole "armed for the next attack →
803/// spent by that attack" half of the laws and stones untested — the arms were
804/// only ever observed being *set*. `ThisAttackDamageBonus` shipped broken
805/// through exactly that gap.
806///
807/// The crit roll is forced off and the power is a constant so two runs of the
808/// same test differ only by what the test changed.
809pub fn test_swing(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
810    let mut sink = NativeSink::default();
811    let params = AttackParams {
812        power: Some(configs::tests_game_config::TEST_SWING_POWER),
813        is_crit: Some(false),
814        no_counterattack: true,
815        ..Default::default()
816    };
817    let mods = ctx
818        .stones
819        .mods_for(ctx.config, ctx.ability_id, ctx.ability_level);
820    run_attack(ctx, &mut sink, &mods, ctx.target_entity, &params)?;
821    Ok(sink.events)
822}
823
824/// The AoE counterpart of [`test_swing`]: one cast, one swing per living enemy,
825/// all inside the cast's own tick.
826///
827/// `SkillMinTargets` counts the distinct targets of a single cast on the tick
828/// that cast was dispatched, so a multi-target condition is only reachable from
829/// a genuinely multi-target ability — hits dispatched on later ticks belong to
830/// no cast and are not counted.
831pub fn test_swing_aoe(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
832    let mut sink = NativeSink::default();
833    let params = AttackParams {
834        power: Some(configs::tests_game_config::TEST_SWING_POWER),
835        is_crit: Some(false),
836        no_counterattack: true,
837        ..Default::default()
838    };
839    let targets: Vec<&Entity> = ctx
840        .fight
841        .entities
842        .iter()
843        .filter(|entity| entity.team != ctx.caster_entity.team && entity.hp > 0)
844        .collect();
845    let mods = ctx
846        .stones
847        .mods_for(ctx.config, ctx.ability_id, ctx.ability_level);
848    for target in targets {
849        run_attack(ctx, &mut sink, &mods, target, &params)?;
850    }
851    Ok(sink.events)
852}
853
854/// Port of the `50260b1c-...` test ability `script`:
855/// RNG-free; used by `test_effects::test_effect_with_interval`.
856pub fn apply_bloodleak_effect(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
857    Ok(vec![
858        OverlordEvent::EntityIncrAttribute {
859            entity_id: ctx.target_entity.id,
860            attribute: "bloodleak".to_string(),
861            delta: 10,
862        },
863        OverlordEvent::EntityApplyEffect {
864            entity_id: ctx.target_entity.id,
865            effect_id: Uuid::parse_str("ccc47912-61c0-4efa-88f9-7911fa1b074f")?,
866            origin: CombatEventOrigin::Core,
867        },
868    ])
869}
870
871/// Test-config only: an AoE strike whose payload, coverage and support stones
872/// all come from the CAST ability's own template, so the fixture can exercise
873/// the coverage and shape supports end to end without a bespoke
874/// `content::ability_info` closure.
875pub fn test_band_strike(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
876    /// Payload for a fixture ability that has no `ability_info` closure.
877    const FALLBACK_POWER: f64 = 5.0;
878    let mut sink = NativeSink::default();
879    let mods = ctx
880        .stones
881        .mods_for(ctx.config, ctx.ability_id, ctx.ability_level);
882    // Same numbers the derived-copy path resolves, so a support's original hit
883    // and its copies are directly comparable.
884    let mut info = ability_info(ctx.config, ctx.lookups, ctx.ability_id, ctx.ability_level)
885        .unwrap_or_else(|_| crate::mechanics::content::AbilityInfo {
886            damage: Some(FALLBACK_POWER),
887            ..crate::mechanics::content::AbilityInfo::none()
888        });
889
890    if info.damage.is_none() {
891        info.damage = Some(FALLBACK_POWER);
892    }
893    info.max_targets = ctx
894        .config
895        .ability_template(ctx.ability_id)
896        .and_then(|template| template.max_targets);
897    info.apply_stone_mods(&mods);
898    for target in band_targets(ctx.fight, ctx.caster_entity, info.max_targets) {
899        let params = AttackParams {
900            power: info.damage,
901            ..Default::default()
902        };
903        run_attack(ctx, &mut sink, &mods, target, &params)?;
904    }
905    Ok(sink.events)
906}
907
908/// Port of the `41ee5532-...` test ability `script`:
909/// `Result.push(OverlordEventEntityApplyEffect(Event.by_entity_id, uuid("3b136901-...")));`
910/// `Event.by_entity_id` is the caster, so the effect is applied to the caster.
911/// RNG-free; used by `test_effects::test_effect_with_subscribe`.
912pub fn apply_low_hp_heal_effect(ctx: &CastAbilityCtx) -> anyhow::Result<Vec<OverlordEvent>> {
913    Ok(vec![OverlordEvent::EntityApplyEffect {
914        entity_id: ctx.caster_entity.id,
915        effect_id: Uuid::parse_str("3b136901-137c-47cc-8c7e-bc0ce387eb1c")?,
916        origin: CombatEventOrigin::Core,
917    }])
918}
919
920/// Register this category's native fns.
921pub fn register(registry: &mut BehaviorRegistry) {
922    let mut reg = |name: &str, title: &str, desc: &str, f: CastAbilityFn| {
923        registry.register_cast_ability(
924            BehaviorMeta {
925                name: name.to_string(),
926                category: BehaviorKind::CastAbility,
927                title: title.to_string(),
928                description: desc.to_string(),
929            },
930            f,
931        );
932    };
933
934    reg(
935        "ability_melee_strike",
936        "Способность: удар по цели (0194d64e)",
937        "on_cast + attack{power} (порт ability script 0194d64e).",
938        melee_strike,
939    );
940    reg(
941        "ability_sword_slash",
942        "Способность: удар по цели (019bff40)",
943        "on_cast + attack{power} (порт ability script 019bff40).",
944        sword_slash,
945    );
946    reg(
947        "ability_mob_melee_quick",
948        "Способность: удар по цели (019cc464)",
949        "on_cast + attack{power} (порт ability script 019cc464).",
950        mob_melee_quick,
951    );
952    reg(
953        "ability_mob_melee_average",
954        "Способность: удар по цели (019cc465-14b8)",
955        "on_cast + attack{power} (порт ability script 019cc465-14b8).",
956        mob_melee_average,
957    );
958    reg(
959        "ability_mob_melee_slow",
960        "Способность: удар по цели (019cc465-2f63)",
961        "on_cast + attack{power} (порт ability script 019cc465-2f63).",
962        mob_melee_slow,
963    );
964    reg(
965        "ability_apply_test_effect",
966        "Способность: наложить test_effect",
967        "apply_entity_effect(Caster, test_effect, 3) (порт ability script 01955be6).",
968        apply_test_effect,
969    );
970    reg(
971        "ability_damage_target_5",
972        "Способность: урон 5 по цели (тест)",
973        "Damage(TargetEntity, 5) — порт общего test ability script.",
974        damage_target_5,
975    );
976    reg(
977        "ability_apply_bloodleak_effect",
978        "Способность: bloodleak +10 + эффект (тест 50260b1c)",
979        "IncrAttribute(Target, bloodleak, 10) + ApplyEffect(Target, ccc47912).",
980        apply_bloodleak_effect,
981    );
982    reg(
983        "ability_test_band_strike",
984        "Способность: тестовый удар по зоне (фикстура)",
985        "attack по врагам в 3-клеточной зоне; охват и payload берутся из шаблона \
986         кастуемой способности и её камней поддержки (только для тестового конфига).",
987        test_band_strike,
988    );
989    reg(
990        "ability_test_swing_aoe",
991        "Способность: настоящий замах по всем врагам (тест)",
992        "attack по каждому живому врагу в тике самого каста — для условий на число целей.",
993        test_swing_aoe,
994    );
995    reg(
996        "ability_test_swing",
997        "Способность: настоящий замах (тест)",
998        "attack{power: TEST_SWING_POWER, is_crit: false} — единственная тестовая \
999         способность, которая доходит до примитивов урона.",
1000        test_swing,
1001    );
1002    reg(
1003        "ability_apply_low_hp_heal_effect",
1004        "Способность: наложить heal-эффект на кастера (тест 41ee5532)",
1005        "ApplyEffect(Caster, 3b136901).",
1006        apply_low_hp_heal_effect,
1007    );
1008    reg(
1009        "ability_crit_strike",
1010        "Способность: удар с бонусом крита (019584aa)",
1011        "on_cast + attack{power, crit_chance_bonus} (порт ability script 019584aa).",
1012        crit_strike,
1013    );
1014    reg(
1015        "ability_cone_strike",
1016        "Способность: конус по фронтальным врагам (019584f4)",
1017        "on_cast, один общий crit-бросок, attack по врагам в 3-клеточной зоне по x \
1018         (порт ability script 019584f4).",
1019        cone_strike,
1020    );
1021    reg(
1022        "ability_multi_projectile",
1023        "Способность: серия снарядов (019589e6)",
1024        "on_cast + N снарядов 0196a6b3 с задержкой i*150 (порт ability script 019589e6).",
1025        multi_projectile,
1026    );
1027    reg(
1028        "ability_strike_then_empower",
1029        "Способность: удар + empower (01958a30)",
1030        "on_cast + attack{power}; при уроне накладывает empower (порт ability script 01958a30).",
1031        strike_then_empower,
1032    );
1033    reg(
1034        "ability_strike_then_vulnerability",
1035        "Способность: удар + vulnerability (01958ef2)",
1036        "on_cast + attack{power}; при уроне накладывает vulnerability на цель \
1037         (порт ability script 01958ef2).",
1038        strike_then_vulnerability,
1039    );
1040    reg(
1041        "ability_night_pounce_projectile",
1042        "Способность: ульта питомца Nyx снарядом (79f90288)",
1043        "on_cast + StartCastProjectile(79f90288); урон ability_crit_strike \
1044         переносится на прилёт снаряда.",
1045        night_pounce_projectile_cast,
1046    );
1047    reg(
1048        "ability_rust_wave_projectile",
1049        "Способность: ульта питомца Rusty снарядом (1ce4597d)",
1050        "on_cast + StartCastProjectile(1ce4597d); конус ability_cone_strike \
1051         переносится на прилёт снаряда.",
1052        rust_wave_projectile_cast,
1053    );
1054    reg(
1055        "ability_odd_flame_projectile",
1056        "Способность: ульта питомца Thingy снарядом (432f231c)",
1057        "on_cast + StartCastProjectile(432f231c); DoT ability_dot_strike \
1058         переносится на прилёт снаряда.",
1059        odd_flame_projectile_cast,
1060    );
1061    reg(
1062        "ability_dot_strike",
1063        "Способность: чистый DoT-удар (01958efd)",
1064        "on_cast + attack{dot_power} (порт ability script 01958efd).",
1065        dot_strike,
1066    );
1067    reg(
1068        "ability_self_hot_heal",
1069        "Способность: HoT-лечение себя (01958ed0)",
1070        "on_cast + spell_heal(Caster, Caster, {hot_power}) (порт ability script 01958ed0).",
1071        self_hot_heal,
1072    );
1073    reg(
1074        "ability_fireball",
1075        "Способность: запуск снаряда 01966cbc (01958172)",
1076        "on_cast + StartCastProjectile(01966cbc) (порт ability script 01958172).",
1077        fireball_cast,
1078    );
1079    reg(
1080        "ability_fireball_projectile_shared",
1081        "Способность: запуск снаряда 01966cbc (общий)",
1082        "on_cast + StartCastProjectile(01966cbc); общий для 8 идентичных ability script \
1083         (019dfc4c/019dfc4f/019dfcfb/019dfcfe/019dfd00/019dfd01/019dfd02-0c6a/019dfd02-add4).",
1084        fireball_projectile_cast,
1085    );
1086    reg(
1087        "ability_vampiric_touch",
1088        "Способность: запуск снаряда 0196a6b4 (019589f7)",
1089        "on_cast + StartCastProjectile(0196a6b4) (порт ability script 019589f7).",
1090        vampiric_touch_cast,
1091    );
1092    reg(
1093        "ability_bare_projectile_mushroom",
1094        "Способность: голый запуск снаряда 019a0244-4675 (019a0245)",
1095        "StartCastProjectile(019a0244-4675) без on_cast (порт ability script 019a0245-ff0c).",
1096        bare_projectile_mushroom,
1097    );
1098    reg(
1099        "ability_bare_projectile_chipmunk",
1100        "Способность: голый запуск снаряда 019a0244-7a0a (019a0246-5aaf)",
1101        "StartCastProjectile(019a0244-7a0a) без on_cast (порт ability script 019a0246-5aaf).",
1102        bare_projectile_chipmunk,
1103    );
1104    reg(
1105        "ability_bare_projectile_bat",
1106        "Способность: голый запуск снаряда 019a0244-aad4 (019a0246-cf87)",
1107        "StartCastProjectile(019a0244-aad4) без on_cast (порт ability script 019a0246-cf87).",
1108        bare_projectile_bat,
1109    );
1110    reg(
1111        "ability_bare_projectile_shot",
1112        "Способность: голый запуск снаряда 019c009c (019c00a4)",
1113        "StartCastProjectile(019c009c) без on_cast (порт ability script 019c00a4).",
1114        bare_projectile_shot,
1115    );
1116
1117    // --- Class kits ---
1118    reg(
1119        "ability_backstab",
1120        "Класс-кит Rogue: Backstab (019dfc4c)",
1121        "on_cast + attack{power, +50% crit} — крит-гэмбл кита.",
1122        backstab_cast,
1123    );
1124    reg(
1125        "ability_thousand_cuts",
1126        "Класс-кит Rogue: Thousand Cuts (019dfc4f)",
1127        "on_cast + 5 прямых ударов по цели (контратака возможна только с первого).",
1128        thousand_cuts_cast,
1129    );
1130    reg(
1131        "ability_arcane_blast",
1132        "Класс-кит Mage: Arcane Blast (019dfcfb)",
1133        "on_cast + attack{power} — весь бюджет одним нюком.",
1134        arcane_blast_cast,
1135    );
1136    reg(
1137        "ability_rewind",
1138        "Класс-кит Mage: Rewind (019dfcfe)",
1139        "on_cast + конус-удар + Weakness на поражённых врагов.",
1140        rewind_cast,
1141    );
1142    reg(
1143        "ability_fortify",
1144        "Класс-кит Warrior: Fortify (019dfd00)",
1145        "on_cast + Protection на себя + инстант-хил остатком бюджета.",
1146        fortify_cast,
1147    );
1148    reg(
1149        "ability_war_cry",
1150        "Класс-кит Warrior: War Cry (019dfd01)",
1151        "on_cast + конус-удар + Empower на себя при попадании.",
1152        war_cry_cast,
1153    );
1154    reg(
1155        "ability_battle_heal",
1156        "Класс-кит Priest: Battle heal (019dfd02-0c6a)",
1157        "on_cast + spell_heal(Caster, Caster, {power}) — инстант-хил всем бюджетом.",
1158        battle_heal_cast,
1159    );
1160    reg(
1161        "ability_holy_nova",
1162        "Класс-кит Priest: Holy Nova (019dfd02-add4)",
1163        "on_cast + конус-удар; кастер лечится за долю нанесённого урона.",
1164        holy_nova_cast,
1165    );
1166}
1167
1168#[cfg(test)]
1169mod band_targets_tests {
1170    use super::band_targets;
1171    use essences::entity::{Coordinates, Entity};
1172    use essences::fighting::{ActiveFight, EntityTeam};
1173
1174    fn enemy_at(id: u128, x: i64) -> Entity {
1175        Entity {
1176            id: uuid::Uuid::from_u128(id),
1177            team: EntityTeam::Enemy,
1178            coordinates: Coordinates { x, y: 0 },
1179            ..Default::default()
1180        }
1181    }
1182
1183    /// §1: the AoE cap takes the FIRST `max_targets` band-eligible enemies in
1184    /// the deterministic `fight.entities` order (no RNG, no sorting); `None` is
1185    /// unlimited (legacy geometry-only behaviour).
1186    #[test]
1187    fn caps_first_n_in_fight_order() {
1188        let caster = Entity {
1189            team: EntityTeam::Ally,
1190            coordinates: Coordinates { x: 0, y: 0 },
1191            ..Default::default()
1192        };
1193        let fight = ActiveFight {
1194            entities: vec![
1195                enemy_at(1, 1),
1196                enemy_at(2, 2),
1197                enemy_at(3, 3),
1198                enemy_at(4, 3),
1199                enemy_at(5, 2),
1200            ],
1201            ..Default::default()
1202        };
1203        let ids = |cap| -> Vec<uuid::Uuid> {
1204            band_targets(&fight, &caster, cap)
1205                .iter()
1206                .map(|e| e.id)
1207                .collect()
1208        };
1209        // Unlimited: all five in-band enemies, in fight order.
1210        assert_eq!(ids(None).len(), 5);
1211        // Cap 3: the first three by fight.entities order.
1212        assert_eq!(
1213            ids(Some(3)),
1214            vec![
1215                uuid::Uuid::from_u128(1),
1216                uuid::Uuid::from_u128(2),
1217                uuid::Uuid::from_u128(3)
1218            ]
1219        );
1220    }
1221
1222    /// The band excludes out-of-band enemies, allies, and units behind the
1223    /// caster — the cap only counts genuinely eligible targets.
1224    #[test]
1225    fn excludes_out_of_band_and_allies() {
1226        let caster = Entity {
1227            team: EntityTeam::Ally,
1228            coordinates: Coordinates { x: 0, y: 0 },
1229            ..Default::default()
1230        };
1231        let ally_in_band = Entity {
1232            id: uuid::Uuid::from_u128(3),
1233            team: EntityTeam::Ally,
1234            coordinates: Coordinates { x: 2, y: 0 },
1235            ..Default::default()
1236        };
1237        let fight = ActiveFight {
1238            entities: vec![
1239                enemy_at(1, 1), // in band
1240                enemy_at(2, 4), // out of band (x > caster_x + 3)
1241                ally_in_band,   // ally in band, excluded
1242                enemy_at(4, 0), // not ahead of caster (x <= caster_x), excluded
1243            ],
1244            ..Default::default()
1245        };
1246        let got: Vec<uuid::Uuid> = band_targets(&fight, &caster, Some(10))
1247            .iter()
1248            .map(|e| e.id)
1249            .collect();
1250        assert_eq!(got, vec![uuid::Uuid::from_u128(1)]);
1251    }
1252}