essences/
entity.rs

1use std::collections::{BTreeMap, HashMap};
2
3use crate::abilities::{AbilityId, ActiveAbility, EquippedAbilities};
4use crate::character_state::CharacterState;
5use crate::class::ClassId;
6use crate::effect::EffectId;
7use crate::fighting::EntityTeam;
8use crate::game::{EnemyReward, EntityTemplateId};
9use crate::items::Item;
10use crate::opponents::OpponentState;
11use crate::pets::{EquippedPets, PetId};
12
13use crate::prelude::*;
14use strum::{EnumIter, IntoEnumIterator};
15
16#[declare]
17pub type EntityId = Uuid;
18
19#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
20pub struct EntityAttributes(pub BTreeMap<String, i64>);
21
22impl EntityAttributes {
23    pub fn add(&mut self, key: &str, delta: i64) {
24        let value = self
25            .0
26            .entry(key.to_owned())
27            .and_modify(|x| *x += delta)
28            .or_insert(delta);
29        if *value == 0 {
30            self.0.remove(key);
31        }
32    }
33
34    pub fn set(&mut self, key: &str, value: i64) {
35        let value = self
36            .0
37            .entry(key.to_owned())
38            .and_modify(|x| *x = value)
39            .or_insert(value);
40        if *value == 0 {
41            self.0.remove(key);
42        }
43    }
44
45    pub fn remove_zeroes(&mut self) {
46        self.0.retain(|_, v| *v != 0);
47    }
48
49    /// Convention: the entity's speed multiplier lives under the `"speed"` attribute key.
50    /// Returns `baseline_speed` when missing, so unmigrated units keep working at 1× cooldown
51    /// rate. `baseline_speed` is sourced from `GameSettings.baseline_speed`.
52    pub fn speed_or_baseline(&self, baseline_speed: u64) -> i64 {
53        self.0
54            .get("speed")
55            .copied()
56            .unwrap_or(baseline_speed as i64)
57    }
58}
59
60#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
61pub struct Coordinates {
62    #[schemars(title = "Координата по х")]
63    pub x: i64,
64    #[schemars(title = "Координата по у")]
65    pub y: i64,
66}
67
68#[derive(Clone, Default, Debug, Copy, Serialize, Hash, Deserialize, PartialEq, Eq, EnumIter)]
69pub enum ActionPriority {
70    #[default]
71    First,
72    Second,
73    Third,
74    Fourth,
75}
76
77// This is a copy of CustomEventData from OES/script, because it cant be imported here and also needs to be defined in OES
78#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
79pub struct EssencesCustomEventData(pub BTreeMap<String, i64>);
80
81#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
82pub enum EntityAction {
83    CastEffect {
84        entity_id: Uuid,
85        effect_id: Uuid,
86    },
87    CastAbility {
88        ability_id: AbilityId,
89        target_entity_id: EntityId,
90    },
91    CastBasicAbility {
92        ability_id: AbilityId,
93        target_entity_id: EntityId,
94    },
95    StartCastAbility {
96        ability_id: AbilityId,
97        by_entity_id: EntityId,
98        pet_id: Option<PetId>,
99    },
100}
101
102impl EntityAction {
103    fn get_action_priority(action: &EntityAction) -> ActionPriority {
104        match action {
105            EntityAction::CastEffect { .. } => ActionPriority::First,
106            EntityAction::CastAbility { .. } => ActionPriority::Second,
107            EntityAction::CastBasicAbility { .. } => ActionPriority::Third,
108            EntityAction::StartCastAbility { .. } => ActionPriority::Fourth,
109        }
110    }
111
112    fn get_actions_priorities(actions: &[Self]) -> Vec<ActionPriority> {
113        actions.iter().map(Self::get_action_priority).collect()
114    }
115
116    fn get_casting_spell_priorities() -> Vec<ActionPriority> {
117        Self::get_actions_priorities(&[
118            Self::CastAbility {
119                ability_id: Uuid::nil(),
120                target_entity_id: Uuid::nil(),
121            },
122            Self::CastBasicAbility {
123                ability_id: Uuid::nil(),
124                target_entity_id: Uuid::nil(),
125            },
126        ])
127    }
128
129    pub fn get_cast_ability_priority() -> ActionPriority {
130        Self::get_action_priority(&Self::CastAbility {
131            ability_id: Uuid::nil(),
132            target_entity_id: Uuid::nil(),
133        })
134    }
135
136    pub fn get_cast_basic_ability_priority() -> ActionPriority {
137        Self::get_action_priority(&Self::CastBasicAbility {
138            ability_id: Uuid::nil(),
139            target_entity_id: Uuid::nil(),
140        })
141    }
142
143    pub fn get_starting_cast_priority() -> ActionPriority {
144        Self::get_action_priority(&Self::StartCastAbility {
145            ability_id: Uuid::nil(),
146            by_entity_id: Uuid::nil(),
147            pet_id: None,
148        })
149    }
150
151    pub fn get_cast_effect_priority() -> ActionPriority {
152        Self::get_action_priority(&Self::CastEffect {
153            entity_id: Uuid::nil(),
154            effect_id: Uuid::nil(),
155        })
156    }
157}
158
159/// Scales a base cooldown duration (in ticks, defined at baseline speed) by the entity's current
160/// speed attribute. Returns the cooldown duration the entity actually experiences.
161///
162/// `scaled = base * baseline_speed / max(speed, 1)`. A speed of `0` or negative is treated as
163/// `baseline_speed`, so unmigrated units keep working at 1×.
164///
165/// Floors at 1 tick when `base > 0` so high speeds never collapse a cooldown to zero via integer
166/// truncation (which would change semantics from "very fast" to "off cooldown forever").
167///
168/// `baseline_speed` is sourced from `GameSettings.baseline_speed`.
169pub fn scale_cooldown_for_speed(base_cooldown_ticks: u64, speed: i64, baseline_speed: u64) -> u64 {
170    if base_cooldown_ticks == 0 {
171        return 0;
172    }
173    let baseline = baseline_speed.max(1);
174    let effective_speed = if speed <= 0 { baseline } else { speed as u64 };
175    let scaled = (base_cooldown_ticks as u128 * baseline as u128) / effective_speed as u128;
176    (scaled as u64).max(1)
177}
178
179#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub struct ActionWithDeadline {
181    pub action: EntityAction,
182    pub deadline_tick: u64,
183}
184
185#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
186pub struct EntityActionsQueue {
187    action_queues: HashMap<ActionPriority, Vec<ActionWithDeadline>>,
188    entity_id: EntityId,
189}
190
191impl EntityActionsQueue {
192    pub fn new(entity_id: EntityId) -> Self {
193        Self {
194            action_queues: HashMap::new(),
195            entity_id,
196        }
197    }
198
199    // Push signle action
200    pub fn push(&mut self, action_with_deadline: &ActionWithDeadline) {
201        let priority = EntityAction::get_action_priority(&action_with_deadline.action);
202
203        self.action_queues
204            .entry(priority)
205            .or_default()
206            .push(ActionWithDeadline {
207                action: action_with_deadline.action.clone(),
208                deadline_tick: action_with_deadline.deadline_tick,
209            });
210    }
211
212    // Add StartCastAbilityAction, depending on what action was triggered
213    fn add_start_cast_ability(
214        &mut self,
215        action_with_deadline: Option<&ActionWithDeadline>,
216        current_tick: u64,
217        ability_id: AbilityId,
218        ability_cooldown: u64,
219    ) {
220        if let Some(action_with_deadline) = action_with_deadline {
221            match action_with_deadline.action {
222                EntityAction::CastAbility { .. } => {
223                    if ability_cooldown != 0 {
224                        self.push(&ActionWithDeadline {
225                            action: EntityAction::StartCastAbility {
226                                ability_id,
227                                by_entity_id: self.entity_id,
228                                pet_id: None,
229                            },
230                            deadline_tick: current_tick + ability_cooldown,
231                        })
232                    }
233                }
234                EntityAction::CastBasicAbility { .. } => {
235                    if ability_cooldown != 0 {
236                        self.push(&ActionWithDeadline {
237                            action: EntityAction::StartCastAbility {
238                                ability_id,
239                                by_entity_id: self.entity_id,
240                                pet_id: None,
241                            },
242                            deadline_tick: current_tick + ability_cooldown,
243                        })
244                    }
245                }
246                EntityAction::StartCastAbility { .. } => {}
247                EntityAction::CastEffect { .. } => {}
248            }
249        } else {
250            self.push(&ActionWithDeadline {
251                action: EntityAction::StartCastAbility {
252                    ability_id,
253                    by_entity_id: self.entity_id,
254                    pet_id: None,
255                },
256                deadline_tick: current_tick,
257            });
258        }
259    }
260
261    // Append multiple actions(casts or move) + add start_cast_ability, depending on action
262    pub fn append_start_cast_ability_result_actions(
263        &mut self,
264        actions_with_deadlines: &Vec<ActionWithDeadline>,
265        current_tick: u64,
266        ability_id: AbilityId,
267        ability_cooldown: u64,
268    ) {
269        for action_with_deadline in actions_with_deadlines {
270            self.push(action_with_deadline);
271        }
272
273        // TODO
274        // If move -> actions_with_deadlines should be empty -> Add StartCastAbility without increased deadline. If CastAbility -> increase deadline by ability cooldown.
275        self.add_start_cast_ability(
276            actions_with_deadlines.first(),
277            current_tick,
278            ability_id,
279            ability_cooldown,
280        );
281    }
282
283    fn is_casting_spell(&self) -> bool {
284        for priority in EntityAction::get_casting_spell_priorities() {
285            if let Some(queue) = self.action_queues.get(&priority)
286                && !queue.is_empty()
287            {
288                return true;
289            }
290        }
291        false
292    }
293
294    fn check_action_is_available(&self, action_priority: &ActionPriority) -> bool {
295        match action_priority {
296            ActionPriority::First => true,
297            ActionPriority::Second => true,
298            ActionPriority::Third => true,
299            ActionPriority::Fourth => !self.is_casting_spell(),
300        }
301    }
302
303    pub fn pop(&mut self, current_tick: u64) -> Option<EntityAction> {
304        for priority in ActionPriority::iter() {
305            if !self.check_action_is_available(&priority) {
306                continue;
307            }
308
309            if let Some(queue) = self.action_queues.get_mut(&priority)
310                && let Some((idx, action_with_deadline)) = queue
311                    .iter()
312                    .enumerate()
313                    .min_by_key(|(_, action_with_deadline)| action_with_deadline.deadline_tick)
314                && action_with_deadline.deadline_tick <= current_tick
315            {
316                let action = queue.remove(idx);
317                return Some(action.action);
318            }
319        }
320
321        None
322    }
323
324    pub fn remove_start_cast_ability_action(&mut self, ability_id_to_remove: AbilityId) {
325        if let Some(queue) = self.action_queues.get_mut(&EntityAction::get_starting_cast_priority()) && let Some(pos) = queue.iter().position(|action_with_deadline| {
326            matches!(
327                action_with_deadline.action,
328                EntityAction::StartCastAbility { ability_id, .. } if ability_id == ability_id_to_remove
329            )
330        }) {
331            queue.remove(pos);
332        }
333    }
334
335    pub fn remove_cast_effect_action(&mut self, effect_id_to_remove: EffectId) {
336        if let Some(queue) = self
337            .action_queues
338            .get_mut(&EntityAction::get_cast_effect_priority())
339            && let Some(pos) = queue.iter().position(|action_with_deadline| {
340                matches!(
341                    action_with_deadline.action,
342                    EntityAction::CastEffect { effect_id, .. } if effect_id == effect_id_to_remove
343                )
344            })
345        {
346            queue.remove(pos);
347        }
348    }
349
350    pub fn get_closest_start_cast_action_deadline(&self) -> Option<u64> {
351        if let Some(queue) = self
352            .action_queues
353            .get(&EntityAction::get_starting_cast_priority())
354        {
355            return queue.iter().map(|action| action.deadline_tick).min();
356        }
357
358        None
359    }
360
361    /// Rescales every `StartCastAbility` (cooldown) entry in the queue to reflect a change in
362    /// the entity's speed attribute.
363    ///
364    /// At speed `S`, a cooldown that was originally `C` ticks long elapses in `C * baseline / S`
365    /// game-ticks. So when speed changes from `S_old` to `S_new`, the still-remaining game-ticks
366    /// for each in-flight cooldown become `(deadline - current_tick) * S_old / S_new`.
367    /// `baseline_speed` falls in for non-positive speed values.
368    ///
369    /// In-flight casts (`CastAbility` / `CastBasicAbility` cast animations) are intentionally not
370    /// touched here — speed scales cooldowns, not cast time.
371    pub fn rescale_cooldowns(
372        &mut self,
373        old_speed: i64,
374        new_speed: i64,
375        current_tick: u64,
376        baseline_speed: u64,
377    ) {
378        if old_speed == new_speed {
379            return;
380        }
381        let baseline = baseline_speed.max(1);
382        let old_speed = if old_speed <= 0 {
383            baseline
384        } else {
385            old_speed as u64
386        };
387        let new_speed = if new_speed <= 0 {
388            baseline
389        } else {
390            new_speed as u64
391        };
392
393        let Some(queue) = self
394            .action_queues
395            .get_mut(&EntityAction::get_starting_cast_priority())
396        else {
397            return;
398        };
399
400        for action in queue.iter_mut() {
401            if !matches!(action.action, EntityAction::StartCastAbility { .. }) {
402                continue;
403            }
404            let remaining = action.deadline_tick.saturating_sub(current_tick);
405            if remaining == 0 {
406                continue;
407            }
408            let scaled = (remaining as u128 * old_speed as u128) / new_speed as u128;
409            let scaled = (scaled as u64).max(1);
410            action.deadline_tick = current_tick + scaled;
411        }
412    }
413
414    /// Applies stun semantics to a single ability:
415    /// - If the ability is currently mid-cast (`CastAbility`/`CastBasicAbility` queued), cancels
416    ///   the cast and sets the cooldown deadline to `current_tick + full_cooldown_ticks +
417    ///   duration_ticks` — full cooldown plus the stun freeze on top.
418    /// - Otherwise, if the ability already has a cooldown entry, extends its deadline by
419    ///   `duration_ticks` (the cooldown effectively pauses for the stun duration).
420    /// - Otherwise (off cooldown), pushes a fresh cooldown entry of `duration_ticks` so the
421    ///   ability stays unusable while stunned.
422    ///
423    /// `full_cooldown_ticks` is the ability's full cooldown (already scaled for entity speed if
424    /// the caller wants speed to apply).
425    pub fn stun_ability(
426        &mut self,
427        ability_id: AbilityId,
428        duration_ticks: u64,
429        full_cooldown_ticks: u64,
430        current_tick: u64,
431    ) {
432        let had_in_flight_cast = [
433            EntityAction::get_cast_ability_priority(),
434            EntityAction::get_cast_basic_ability_priority(),
435        ]
436        .iter()
437        .any(|priority| {
438            self.action_queues
439                .get(priority)
440                .is_some_and(|queue| {
441                    queue.iter().any(|action_with_deadline| {
442                        matches!(
443                            action_with_deadline.action,
444                            EntityAction::CastAbility { ability_id: aid, .. } | EntityAction::CastBasicAbility { ability_id: aid, .. } if aid == ability_id
445                        )
446                    })
447                })
448        });
449
450        if had_in_flight_cast {
451            self.cancel_cast_and_set_cooldown(
452                ability_id,
453                current_tick
454                    .saturating_add(full_cooldown_ticks)
455                    .saturating_add(duration_ticks),
456            );
457            return;
458        }
459
460        if self.adjust_ability_cooldown(ability_id, duration_ticks as i64, current_tick) {
461            return;
462        }
463
464        // No existing cooldown — push a fresh stun-only cooldown.
465        let entity_id = self.entity_id;
466        self.push(&ActionWithDeadline {
467            action: EntityAction::StartCastAbility {
468                ability_id,
469                by_entity_id: entity_id,
470                pet_id: None,
471            },
472            deadline_tick: current_tick.saturating_add(duration_ticks),
473        });
474    }
475
476    /// Cancels any in-flight cast (CastAbility / CastBasicAbility) for `ability_id_to_cancel`
477    /// and replaces the cooldown entry (StartCastAbility) with a new one at `new_deadline_tick`.
478    /// Returns `true` when an in-flight cast was found and removed.
479    pub fn cancel_cast_and_set_cooldown(
480        &mut self,
481        ability_id_to_cancel: AbilityId,
482        new_deadline_tick: u64,
483    ) -> bool {
484        let mut had_in_flight = false;
485        for priority in [
486            EntityAction::get_cast_ability_priority(),
487            EntityAction::get_cast_basic_ability_priority(),
488        ] {
489            if let Some(queue) = self.action_queues.get_mut(&priority) {
490                let original_len = queue.len();
491                queue.retain(|action_with_deadline| {
492                    !matches!(
493                        action_with_deadline.action,
494                        EntityAction::CastAbility { ability_id, .. } if ability_id == ability_id_to_cancel
495                    ) && !matches!(
496                        action_with_deadline.action,
497                        EntityAction::CastBasicAbility { ability_id, .. } if ability_id == ability_id_to_cancel
498                    )
499                });
500                if queue.len() != original_len {
501                    had_in_flight = true;
502                }
503            }
504        }
505
506        if let Some(queue) = self
507            .action_queues
508            .get_mut(&EntityAction::get_starting_cast_priority())
509        {
510            queue.retain(|action_with_deadline| {
511                !matches!(
512                    action_with_deadline.action,
513                    EntityAction::StartCastAbility { ability_id, .. } if ability_id == ability_id_to_cancel
514                )
515            });
516        }
517
518        let entity_id = self.entity_id;
519        self.push(&ActionWithDeadline {
520            action: EntityAction::StartCastAbility {
521                ability_id: ability_id_to_cancel,
522                by_entity_id: entity_id,
523                pet_id: None,
524            },
525            deadline_tick: new_deadline_tick,
526        });
527
528        had_in_flight
529    }
530
531    /// Adjusts the cooldown for a specific ability by `delta_ticks`.
532    /// Positive delta extends the cooldown, negative shortens it (saturating at `current_tick`,
533    /// i.e. no remaining cooldown). Returns `true` when an entry was found and adjusted.
534    pub fn adjust_ability_cooldown(
535        &mut self,
536        ability_id_to_adjust: AbilityId,
537        delta_ticks: i64,
538        current_tick: u64,
539    ) -> bool {
540        let Some(queue) = self
541            .action_queues
542            .get_mut(&EntityAction::get_starting_cast_priority())
543        else {
544            return false;
545        };
546
547        let Some(action) = queue.iter_mut().find(|action_with_deadline| {
548            matches!(
549                action_with_deadline.action,
550                EntityAction::StartCastAbility { ability_id, .. } if ability_id == ability_id_to_adjust
551            )
552        }) else {
553            return false;
554        };
555
556        action.deadline_tick = if delta_ticks >= 0 {
557            action.deadline_tick.saturating_add(delta_ticks as u64)
558        } else {
559            let abs = (-delta_ticks) as u64;
560            action.deadline_tick.saturating_sub(abs).max(current_tick)
561        };
562
563        true
564    }
565
566    pub fn view(&self) -> HashMap<ActionPriority, Vec<ActionWithDeadline>> {
567        self.action_queues.clone()
568    }
569}
570
571#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
572#[tsify(from_wasm_abi, into_wasm_abi)]
573pub struct Entity {
574    pub id: EntityId,
575    pub max_hp: u64,
576    pub hp: u64,
577    pub abilities: Vec<ActiveAbility>,
578    #[schemars(skip)]
579    pub actions_queue: EntityActionsQueue,
580    pub attributes: EntityAttributes,
581    pub effect_ids: Vec<EffectId>,
582    pub coordinates: Coordinates,
583    /// Destination of the in-flight run, `None` when not moving. Also reserves
584    /// the cell so a concurrently-planning opponent will not run onto it.
585    pub move_target: Option<Coordinates>,
586    pub width: i8, // not ENUM because JsonSchema and Tsify can't be friends // TODO I am not sure anymore
587    pub rewards: Option<Vec<EnemyReward>>,
588    pub class_id: Option<ClassId>,
589    pub team: EntityTeam,
590    pub has_big_hp_bar: bool,
591    pub entity_template_id: Option<EntityTemplateId>,
592}
593
594#[derive(Debug, Clone, Eq, PartialEq)]
595pub enum EntityState<'a> {
596    Character(&'a CharacterState),
597    Opponent(&'a OpponentState),
598}
599
600impl<'a> EntityState<'a> {
601    pub fn id(&self) -> uuid::Uuid {
602        match self {
603            EntityState::Character(character_state) => character_state.character.id,
604            EntityState::Opponent(opponent_state) => opponent_state.id(),
605        }
606    }
607
608    pub fn level(&self) -> i64 {
609        match self {
610            EntityState::Character(character_state) => character_state.character.character_level,
611            EntityState::Opponent(opponent_state) => opponent_state.level(),
612        }
613    }
614
615    pub fn inventory(&self) -> &Vec<Item> {
616        match self {
617            EntityState::Character(character_state) => &character_state.inventory,
618            EntityState::Opponent(opponent_state) => opponent_state.inventory(),
619        }
620    }
621
622    pub fn class(&self) -> ClassId {
623        match self {
624            EntityState::Character(character_state) => character_state.character.class,
625            EntityState::Opponent(opponent_state) => opponent_state.class(),
626        }
627    }
628
629    pub fn equipped_abilities(&self) -> &EquippedAbilities {
630        match self {
631            EntityState::Character(character_state) => &character_state.equipped_abilities,
632            EntityState::Opponent(opponent_state) => opponent_state.equipped_abilities(),
633        }
634    }
635
636    pub fn equipped_pets(&self) -> Option<&EquippedPets> {
637        match self {
638            EntityState::Character(character_state) => Some(&character_state.equipped_pets),
639            EntityState::Opponent(opponent_state) => opponent_state.equipped_pets(),
640        }
641    }
642}