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