overlord_event_system/behaviors/combat/
start_cast.rs

1//! Native ports for the `start_cast_ability` category — ability `start_behavior`
2//! (run via `run_start_cast_ability`, returns `Vec<StartCastAbilityResult>`).
3//!
4//! Unlike the data categories, these run combat logic that consumes the
5//! authoritative RNG (`try_cast`'s multicast roll, attack rolls, ...), drawing
6//! values from the game `Random` in order.
7//!
8//! The 27 shipped ability start_scripts are dominated by
9//! `ctx.try_cast(CasterEntity, "<self ability id>")` — one parameterised native
10//! fn ([`try_cast_self`]) covers them; the small attack-based tail gets its own
11//! fns.
12
13use configs::abilities::ProjectileId;
14use configs::game_config::GameConfig;
15use essences::abilities::AbilityId;
16use essences::combat_origin::CombatEventOrigin;
17use essences::entity::Entity;
18use essences::entity::{ActionWithDeadline, Coordinates, EntityAction, EntityId};
19use essences::fighting::ActiveFight;
20use event_system::event::EventPluginized;
21use event_system::script::random::GameRng;
22use serde::Serialize;
23
24use crate::event::{CustomEventData, OverlordEvent};
25use crate::game_config_helpers::GameConfigLookup;
26use crate::state::OverlordState;
27use uuid::Uuid;
28
29use crate::behaviors::{BehaviorKind, BehaviorMeta, BehaviorRegistry};
30use crate::mechanics::content_lookups::ContentLookups;
31use crate::mechanics::fight::{self, NativeSink};
32
33/// `start_behavior` sees (`CasterEntity`, `Fight`, `Random`) plus the cast ability
34/// id and config/lookups the combat API needs.
35pub struct StartCastAbilityCtx<'a> {
36    pub caster: &'a Entity,
37    pub fight: &'a ActiveFight,
38    /// RNG snapshot (a clone of the authoritative stream at the same state) so
39    pub rng: &'a GameRng,
40    /// The ability being cast (its template id) — the dominant
41    /// `try_cast(CasterEntity, "<self>")` pattern casts the caster's own ability.
42    pub ability_template_id: Uuid,
43    pub config: &'a GameConfig,
44    pub lookups: &'a ContentLookups,
45}
46
47/// Signature of a `start_cast_ability` native fn.
48pub type StartCastAbilityFn =
49    fn(&StartCastAbilityCtx) -> anyhow::Result<Vec<StartCastAbilityResult>>;
50
51/// Port of `ctx.try_cast(CasterEntity, "<self ability id>")` — the dominant
52/// wrapper does (casts=1 default; identical RNG consumption: multicast roll,
53/// valid-target filtering, per-cast rolls), then classifies the pushed results.
54pub fn try_cast_self(ctx: &StartCastAbilityCtx) -> anyhow::Result<Vec<StartCastAbilityResult>> {
55    let mut sink = NativeSink::default();
56    fight::try_cast(
57        &mut sink,
58        ctx.rng,
59        ctx.config,
60        ctx.lookups,
61        ctx.fight,
62        ctx.caster,
63        ctx.ability_template_id,
64        1,
65    )
66    .map_err(|e| anyhow::anyhow!("try_cast: {e}"))?;
67    StartCastAbilityResult::vec_from_script_results(&sink.casts)
68}
69
70/// Port of `Result.push_attack(unsigned(0), unsigned(400), CasterEntity.id)` — a
71/// single self-targeting attack (delay 0, anim 400 ticks). No RNG. Used by the
72/// one non-`try_cast` ability start_behavior (01955be6).
73pub fn self_attack_400(ctx: &StartCastAbilityCtx) -> anyhow::Result<Vec<StartCastAbilityResult>> {
74    Ok(vec![StartCastAbilityResult::Attack {
75        delay_ticks: 0,
76        animation_duration_ticks: 400,
77        target_entity_id: ctx.caster.id,
78        origin: CombatEventOrigin::Core,
79    }])
80}
81
82/// Port of the shared test `start_behavior` (`generate_start_ability_script`):
83/// Attacks the first entity on the opposing team (anim 500). RNG-free.
84pub fn attack_first_enemy(
85    ctx: &StartCastAbilityCtx,
86) -> anyhow::Result<Vec<StartCastAbilityResult>> {
87    let Some(target) = ctx
88        .fight
89        .entities
90        .iter()
91        .find(|e| e.team != ctx.caster.team)
92    else {
93        return Ok(vec![]);
94    };
95    Ok(vec![StartCastAbilityResult::Attack {
96        delay_ticks: 0,
97        animation_duration_ticks: 500,
98        target_entity_id: target.id,
99        origin: CombatEventOrigin::Core,
100    }])
101}
102
103/// Port of `Result.push_attack(unsigned(0), unsigned(500), CasterEntity.id);`
104/// (the `41ee5532-...` test ability `start_behavior`) — a single self-targeting
105/// attack (delay 0, anim 500 ticks). RNG-free.
106pub fn self_attack_500(ctx: &StartCastAbilityCtx) -> anyhow::Result<Vec<StartCastAbilityResult>> {
107    Ok(vec![StartCastAbilityResult::Attack {
108        delay_ticks: 0,
109        animation_duration_ticks: 500,
110        target_entity_id: ctx.caster.id,
111        origin: CombatEventOrigin::Core,
112    }])
113}
114
115/// Register this category's native fns.
116fn register_ability_fns(registry: &mut BehaviorRegistry) {
117    registry.register_start_cast_ability(
118        BehaviorMeta {
119            name: "try_cast_self".to_string(),
120            category: BehaviorKind::StartCastAbility,
121            title: "Авто-каст своей абилки".to_string(),
122            description: "Порт start_behavior `ctx.try_cast(CasterEntity, <self ability id>)` — \
123                кастует абилку кастера через try_cast (casts=1)."
124                .to_string(),
125        },
126        try_cast_self,
127    );
128    registry.register_start_cast_ability(
129        BehaviorMeta {
130            name: "self_attack_400".to_string(),
131            category: BehaviorKind::StartCastAbility,
132            title: "Само-атака (anim 400)".to_string(),
133            description: "Порт `Result.push_attack(0, 400, CasterEntity.id)` — одиночная \
134                атака по себе, без RNG."
135                .to_string(),
136        },
137        self_attack_400,
138    );
139    registry.register_start_cast_ability(
140        BehaviorMeta {
141            name: "attack_first_enemy".to_string(),
142            category: BehaviorKind::StartCastAbility,
143            title: "Атака по первому врагу (anim 500)".to_string(),
144            description: "Порт общего test start_behavior: атака по первой сущности \
145                вражеской команды, anim 500."
146                .to_string(),
147        },
148        attack_first_enemy,
149    );
150    registry.register_start_cast_ability(
151        BehaviorMeta {
152            name: "self_attack_500".to_string(),
153            category: BehaviorKind::StartCastAbility,
154            title: "Само-атака (anim 500)".to_string(),
155            description: "Порт `Result.push_attack(0, 500, CasterEntity.id)` \
156                (test ability 41ee5532)."
157                .to_string(),
158        },
159        self_attack_500,
160    );
161}
162// Native functions for the `start_cast_projectile` category — the projectile
163// `start_behavior` slot (`run_start_cast_projectile` in `script.rs`, called from
164// `handle_start_cast_projectile`).
165//
166// Despite the rollout's "needs_fight_context" hint, the scripts this slot
167// actually runs (`projectile.start_behavior`) are **pure geometry** — they never
168// `import "fight_context"`, never call `ctx.attack/on_cast`, and never touch
169// `Result.projectile_data`. (The `fight_context` logic lives in the sibling
170// `projectile.script` slot — the `CastProjectile` event handler — which is a
171// *different* category, not this one.) Every shipped `start_behavior` is one of
172// exactly two shapes:
173//
174//
175// Each distinct shape/constant is a separately-named native fn (config
176// references the one matching the projectile), exactly as `power.rs` keeps one
177// `fn` per behavior. `Result.projectile_data` is left at its default (empty)
178// map in every case, matching the scripts.
179//
180// ## Marshalling fidelity
181// * `(xc - xt) ** 2` is `INT ** INT` → stays `INT` (integer power).
182//   so `distance` is `((x2 + y2) as f64).powf(0.5)` (i.e. `sqrt`).
183// * `distance * M` is `FLOAT * INT` → `FLOAT`.
184// * `floor(FLOAT)` → `INT` (`f64::floor` then `as i64`, per the engine's
185//   `register_fn("floor", |val: f64| val.floor() as i64)`).
186// * `unsigned(INT)` → `u64` (`x as u64`, per `register_fn("unsigned", ...)`).
187//
188// The native port performs the squares in `i64`, the `sqrt`/scale in `f64`,
189// then `floor`→`i64`→`as u64`, reproducing the engine ops bit-for-bit.
190
191/// Inputs available to a `start_cast_projectile` native fn — the subset of the
192/// scope (`run_start_cast_projectile` via `handle_start_cast_projectile`) also
193/// binds `CasterEntity`, `Fight`, `ProjectileLevel`, `CurrentTick`,
194/// `FightDurationTicks` and the event, but no shipped `start_behavior` reads
195/// anything beyond the caster/target coordinates; add fields here if a future
196/// script does.
197pub struct StartCastProjectileCtx<'a> {
198    pub caster_entity: &'a Entity,
199    pub target_entity: &'a Entity,
200}
201
202/// Signature of a `start_cast_projectile` native fn. Free `fn` (no captured
203/// state) so it is `Copy` and trivially stored in the registry; runtime context
204/// arrives via [`StartCastProjectileCtx`].
205pub type StartCastProjectileFn =
206    fn(&StartCastProjectileCtx) -> anyhow::Result<StartCastProjectileResult>;
207
208/// squares in `i64` (`INT ** INT`), then `((x2 + y2) as f64).powf(0.5)`
209/// (`INT ** FLOAT` promotes the base to `FLOAT`).
210fn caster_target_distance(ctx: &StartCastProjectileCtx) -> f64 {
211    let dx = ctx.caster_entity.coordinates.x - ctx.target_entity.coordinates.x;
212    let dy = ctx.caster_entity.coordinates.y - ctx.target_entity.coordinates.y;
213    // `(xc - xt) ** 2` / `(yc - yt) ** 2` — integer power, kept in i64.
214    let x2 = dx * dx;
215    let y2 = dy * dy;
216    // exponent, i.e. powf(0.5) == sqrt.
217    ((x2 + y2) as f64).powf(0.5)
218}
219
220/// Shared distance-based body: `unsigned(floor(distance * multiplier))`, leaving
221/// `projectile_data` at its default (empty) — matching the shipped scripts.
222fn distance_animation(
223    ctx: &StartCastProjectileCtx,
224    multiplier: f64,
225) -> anyhow::Result<StartCastProjectileResult> {
226    let distance = caster_target_distance(ctx);
227    // `distance * M` is FLOAT; `floor(FLOAT)` → i64 (engine `floor`); then
228    // `unsigned(i64)` → u64 (engine `unsigned`, `x as u64`).
229    let ticks = (distance * multiplier).floor() as i64 as u64;
230    Ok(StartCastProjectileResult {
231        projectile_data: Default::default(),
232        animation_duration_ticks: ticks,
233    })
234}
235
236/// Native port of the distance×100 `start_behavior` (the common case).
237pub fn distance_x100(ctx: &StartCastProjectileCtx) -> anyhow::Result<StartCastProjectileResult> {
238    distance_animation(ctx, 100.0)
239}
240
241/// Native port of the distance×380 `start_behavior`
242/// (projectile `0196a6b3-f885-7fdc-af8c-92a1ffb79ceb`).
243pub fn distance_x380(ctx: &StartCastProjectileCtx) -> anyhow::Result<StartCastProjectileResult> {
244    distance_animation(ctx, 380.0)
245}
246
247/// Native port of the fixed `Result.animation_duration_ticks = unsigned(200)`
248/// `start_behavior` (projectile `019aeeed-5bcc-7dcc-a74f-589031a6b8f2`).
249pub fn fixed_200(_ctx: &StartCastProjectileCtx) -> anyhow::Result<StartCastProjectileResult> {
250    Ok(StartCastProjectileResult {
251        projectile_data: Default::default(),
252        animation_duration_ticks: 200,
253    })
254}
255
256/// Port of the test projectile `start_behavior`
257/// `Result.animation_duration_ticks = unsigned(500); Result.projectile_data.add("damage", 300);`
258/// — fixed 500-tick anim with a `damage=300` entry in `projectile_data`. Used by
259/// `test_fighting_abilities`.
260pub fn fixed_500_damage_300(
261    _ctx: &StartCastProjectileCtx,
262) -> anyhow::Result<StartCastProjectileResult> {
263    let mut projectile_data = crate::event::CustomEventData::default();
264    projectile_data.add("damage", 300);
265    Ok(StartCastProjectileResult {
266        projectile_data,
267        animation_duration_ticks: 500,
268    })
269}
270
271/// Register this category's native fns into the registry.
272fn register_projectile_fns(registry: &mut BehaviorRegistry) {
273    registry.register_start_cast_projectile(
274        BehaviorMeta {
275            name: "projectile_fixed_500_damage_300".to_string(),
276            category: BehaviorKind::StartCastProjectile,
277            title: "Снаряд: 500 тиков + damage=300 (тест)".to_string(),
278            description: "Фиксированные 500 тиков анимации; projectile_data = {damage: 300} \
279                (порт test projectile start_behavior)."
280                .to_string(),
281        },
282        fixed_500_damage_300,
283    );
284    registry.register_start_cast_projectile(
285        BehaviorMeta {
286            name: "projectile_distance_x100".to_string(),
287            category: BehaviorKind::StartCastProjectile,
288            title: "Снаряд: длительность по дистанции (×100)".to_string(),
289            description: "unsigned(floor(дистанция_кастер_цель * 100)) тиков анимации; \
290                projectile_data пустой (порт projectile start_behavior ×100)."
291                .to_string(),
292        },
293        distance_x100,
294    );
295    registry.register_start_cast_projectile(
296        BehaviorMeta {
297            name: "projectile_distance_x380".to_string(),
298            category: BehaviorKind::StartCastProjectile,
299            title: "Снаряд: длительность по дистанции (×380)".to_string(),
300            description: "unsigned(floor(дистанция_кастер_цель * 380)) тиков анимации; \
301                projectile_data пустой (порт projectile start_behavior ×380)."
302                .to_string(),
303        },
304        distance_x380,
305    );
306    registry.register_start_cast_projectile(
307        BehaviorMeta {
308            name: "projectile_fixed_200".to_string(),
309            category: BehaviorKind::StartCastProjectile,
310            title: "Снаряд: фиксированная длительность 200".to_string(),
311            description: "Фиксированные 200 тиков анимации; projectile_data пустой \
312                (порт projectile start_behavior unsigned(200))."
313                .to_string(),
314        },
315        fixed_200,
316    );
317}
318
319/// Register both start-cast scopes (ability wind-up + projectile wind-up).
320pub fn register(registry: &mut BehaviorRegistry) {
321    register_ability_fns(registry);
322    register_projectile_fns(registry);
323}
324
325/// One pushed result from an ability `start_behavior` — either an attack or a run.
326/// The native start-cast-ability port fills a `Vec` of these, which
327/// [`StartCastAbilityResult::vec_from_script_results`] classifies into typed
328/// [`StartCastAbilityResult`]s.
329#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct StartCastAbilityScriptResult {
331    pub delay_ticks: Option<u64>,
332    pub animation_duration_ticks: Option<u64>,
333    pub target_entity_id: Option<Uuid>,
334
335    pub coordinates: Option<Coordinates>,
336    pub run_duration_ticks: Option<u64>,
337
338    /// Provenance of the scope that pushed this cast — `Core` for the fight
339    /// engine itself, non-Core when a modifier queued the swing through an
340    /// [`crate::mechanics::fight::OriginSink`]. It reaches the queued
341    /// [`ActionWithDeadline`] so the damage the cast eventually lands cannot
342    /// surface as Core.
343    pub origin: CombatEventOrigin,
344}
345
346#[derive(Clone, Debug, PartialEq, serde::Serialize)]
347pub enum StartCastAbilityResult {
348    Run {
349        coordinates: Coordinates,
350        run_duration_ticks: u64,
351        /// See [`StartCastAbilityScriptResult::origin`].
352        origin: CombatEventOrigin,
353    },
354    Attack {
355        delay_ticks: u64,
356        animation_duration_ticks: u64,
357        target_entity_id: Uuid,
358        /// See [`StartCastAbilityScriptResult::origin`].
359        origin: CombatEventOrigin,
360    },
361    None,
362}
363
364impl StartCastAbilityResult {
365    /// Convert the raw pushed results (`StartCastAbilityScriptResult`) into typed
366    /// `StartCastAbilityResult`s, applying Run/Attack/None classification +
367    /// validation.
368    pub fn vec_from_script_results(
369        results: &[StartCastAbilityScriptResult],
370    ) -> anyhow::Result<Vec<StartCastAbilityResult>> {
371        let mut converted_results = Vec::new();
372        let mut running = false;
373        for result in results.iter().cloned() {
374            if result.run_duration_ticks.is_some() && result.animation_duration_ticks.is_some() {
375                anyhow::bail!(
376                    "Attack and run provided in one singular result {:?}",
377                    result
378                )
379            }
380            let converted_result = if let (Some(run_duration_ticks), Some(coordinates)) =
381                (result.run_duration_ticks, result.coordinates)
382            {
383                running = true;
384                StartCastAbilityResult::Run {
385                    coordinates,
386                    run_duration_ticks,
387                    origin: result.origin,
388                }
389            } else if let (
390                Some(animation_duration_ticks),
391                Some(target_entity_id),
392                Some(delay_ticks),
393            ) = (
394                result.animation_duration_ticks,
395                result.target_entity_id,
396                result.delay_ticks,
397            ) {
398                StartCastAbilityResult::Attack {
399                    delay_ticks,
400                    animation_duration_ticks,
401                    target_entity_id,
402                    origin: result.origin,
403                }
404            } else {
405                StartCastAbilityResult::None
406            };
407            converted_results.push(converted_result);
408        }
409        if running && converted_results.len() > 1 {
410            anyhow::bail!("More than 1 result in start_cast_ability with running {results:?}")
411        }
412        Ok(converted_results)
413    }
414
415    /// `dispatch_origin` is the provenance of the dispatch that ran the
416    /// `start_behavior`; the result's own origin is the scope that pushed the
417    /// cast. Marking only upgrades, so the queued action carries whichever of
418    /// the two is non-Core.
419    pub fn into_entity_action_with_deadline(
420        &self,
421        class_id: Uuid,
422        game_config: &GameConfig,
423        ability_id: AbilityId,
424        current_tick: u64,
425        dispatch_origin: CombatEventOrigin,
426    ) -> anyhow::Result<ActionWithDeadline> {
427        match self {
428            StartCastAbilityResult::Run { .. } => {
429                anyhow::bail!("Got Run for StartCastAbilityResult into_entity_action")
430            }
431            StartCastAbilityResult::Attack {
432                delay_ticks,
433                animation_duration_ticks,
434                target_entity_id,
435                origin,
436            } => {
437                let Some(class) = game_config.class(class_id) else {
438                    anyhow::bail!("Failed to get class with id: {}", class_id);
439                };
440
441                let action = if !class.basic_abilities.contains(&ability_id) {
442                    EntityAction::CastAbility {
443                        ability_id,
444                        target_entity_id: *target_entity_id,
445                    }
446                } else {
447                    EntityAction::CastBasicAbility {
448                        ability_id,
449                        target_entity_id: *target_entity_id,
450                    }
451                };
452
453                Ok(ActionWithDeadline {
454                    action,
455                    deadline_tick: current_tick + *delay_ticks + *animation_duration_ticks,
456                    origin: origin.merge(dispatch_origin),
457                })
458            }
459            StartCastAbilityResult::None => {
460                anyhow::bail!("Got NONE for StartCastAbilityResult into_entity_action")
461            }
462        }
463    }
464
465    pub fn into_event(
466        &self,
467        ability_id: AbilityId,
468        by_entity_id: EntityId,
469        dispatch_origin: CombatEventOrigin,
470    ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
471        match self {
472            StartCastAbilityResult::Run {
473                coordinates,
474                run_duration_ticks,
475                ..
476            } => Some(EventPluginized::now(OverlordEvent::StartMove {
477                entity_id: by_entity_id,
478                to: coordinates.clone(),
479                duration_ticks: *run_duration_ticks,
480            })),
481            StartCastAbilityResult::Attack {
482                delay_ticks,
483                animation_duration_ticks,
484                origin,
485                ..
486            } => Some(EventPluginized::delayed(
487                OverlordEvent::StartedCastAbility {
488                    by_entity_id,
489                    ability_id,
490                    duration_ticks: *animation_duration_ticks,
491                    origin: origin.merge(dispatch_origin),
492                },
493                *delay_ticks,
494            )),
495            StartCastAbilityResult::None => None,
496        }
497    }
498
499    #[allow(clippy::type_complexity)]
500    pub fn vec_into_actions_with_deadlines_and_events(
501        results: &Vec<StartCastAbilityResult>,
502        class_id: Uuid,
503        game_config: &GameConfig,
504        ability_id: AbilityId,
505        entity_id: EntityId,
506        current_tick: u64,
507        dispatch_origin: CombatEventOrigin,
508    ) -> anyhow::Result<(
509        Vec<ActionWithDeadline>,
510        Vec<EventPluginized<OverlordEvent, OverlordState>>,
511    )> {
512        let mut actions = vec![];
513        let mut events = vec![];
514
515        for result in results {
516            if matches!(result, StartCastAbilityResult::Attack { .. }) {
517                actions.push(result.into_entity_action_with_deadline(
518                    class_id,
519                    game_config,
520                    ability_id,
521                    current_tick,
522                    dispatch_origin,
523                )?);
524            }
525
526            if let Some(event) = result.into_event(ability_id, entity_id, dispatch_origin) {
527                events.push(event);
528            }
529        }
530
531        Ok((actions, events))
532    }
533}
534
535#[derive(Debug, Clone, Default, PartialEq, Serialize)]
536pub struct StartCastProjectileResult {
537    pub projectile_data: CustomEventData,
538    pub animation_duration_ticks: u64,
539}
540
541impl StartCastProjectileResult {
542    pub fn into_frontend_event(
543        &self,
544        by_entity_id: EntityId,
545        to_entity_id: EntityId,
546        projectile_id: ProjectileId,
547        source: essences::fight_breakdown::CombatSource,
548    ) -> EventPluginized<OverlordEvent, OverlordState> {
549        EventPluginized::now(OverlordEvent::StartedCastProjectile {
550            by_entity_id,
551            to_entity_id,
552            projectile_id,
553            duration_ticks: self.animation_duration_ticks,
554            origin: CombatEventOrigin::Core,
555            source,
556        })
557    }
558}