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::combat_origin::CombatEventOrigin;
7use crate::effect::EffectId;
8use crate::fighting::EntityTeam;
9use crate::game::{EnemyReward, EntityTemplateId};
10use crate::items::Item;
11use crate::opponents::OpponentState;
12use crate::pets::{EquippedPets, PetId};
13
14use crate::prelude::*;
15use strum::{EnumIter, IntoEnumIterator};
16
17#[declare]
18pub type EntityId = Uuid;
19
20#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
21pub struct EntityAttributes(pub BTreeMap<String, i64>);
22
23impl EntityAttributes {
24    pub fn add(&mut self, key: &str, delta: i64) {
25        let value = self
26            .0
27            .entry(key.to_owned())
28            .and_modify(|x| *x += delta)
29            .or_insert(delta);
30        if *value == 0 {
31            self.0.remove(key);
32        }
33    }
34
35    pub fn set(&mut self, key: &str, value: i64) {
36        let value = self
37            .0
38            .entry(key.to_owned())
39            .and_modify(|x| *x = value)
40            .or_insert(value);
41        if *value == 0 {
42            self.0.remove(key);
43        }
44    }
45
46    pub fn remove_zeroes(&mut self) {
47        self.0.retain(|_, v| *v != 0);
48    }
49
50    /// Boss-summon-wave: the mob was spawned by a boss summon wave (marked with
51    /// the `"summoned"` attribute in `spawn_wave`). Summoned reinforcements carry
52    /// NO economy weight — their `wave_share()` is 0, so per-kill drops, pet-ult
53    /// charge and counterattack procs from them are zero, and kill quests skip
54    /// their deaths. This keeps a boss fight's total faucet flat when the summon
55    /// wave is added (the boss keeps its full, unscaled payout).
56    pub fn is_summoned(&self) -> bool {
57        self.0.contains_key("summoned")
58    }
59
60    /// Growing-enemy-waves §2: the mob's reward/weight share as a fraction
61    /// (stored per-10000 under `"wave_share"`, like `crit_chance`). Absent ⇒ 1.0
62    /// (legacy content with no `reward_mob_budget`). Consumers multiply per-kill
63    /// drop chance, pet-ult charge fill and counterattack proc chance by this so
64    /// they stay count-invariant when concurrency is inflated. Boss-summoned
65    /// reinforcements return 0 (no net faucet, see `is_summoned`).
66    pub fn wave_share(&self) -> f64 {
67        if self.is_summoned() {
68            return 0.0;
69        }
70        self.0.get("wave_share").copied().unwrap_or(10000) as f64 / 10000.0
71    }
72
73    /// BAL-026: this combatant's share of its roster's initial max-HP budget
74    /// (stored per-10000 under `"gauge_hp_share"`, stamped at spawn). It
75    /// normalizes the OUTGOING damage gauge, so absolute stats never speed the
76    /// bar up: killing a full wave is worth the same gauge at any power.
77    /// Absent ⇒ 1.0 (a solo roster); summoned reinforcements return 0 and
78    /// never move the gauge. A NEGATIVE stamp is the explicit "worth nothing"
79    /// marker (BAL-026's invalid-fixture rule — the attribute map drops
80    /// literal zeros, so 0 cannot be stored directly) and reads as 0.
81    pub fn gauge_hp_share(&self) -> f64 {
82        if self.is_summoned() {
83            return 0.0;
84        }
85        self.0
86            .get("gauge_hp_share")
87            .copied()
88            .unwrap_or(10000)
89            .max(0) as f64
90            / 10000.0
91    }
92
93    /// Convention: the entity's speed multiplier lives under the `"speed"` attribute key.
94    /// Returns `baseline_speed` when missing, so unmigrated units keep working at 1× cooldown
95    /// rate. `baseline_speed` is sourced from `GameSettings.baseline_speed`.
96    pub fn speed_or_baseline(&self, baseline_speed: u64) -> i64 {
97        self.0
98            .get("speed")
99            .copied()
100            .unwrap_or(baseline_speed as i64)
101    }
102}
103
104#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
105pub struct Coordinates {
106    #[schemars(title = "Координата по х")]
107    pub x: i64,
108    #[schemars(title = "Координата по у")]
109    pub y: i64,
110}
111
112#[derive(Clone, Default, Debug, Copy, Serialize, Hash, Deserialize, PartialEq, Eq, EnumIter)]
113pub enum ActionPriority {
114    #[default]
115    First,
116    Second,
117    Third,
118    Fourth,
119}
120
121// This is a copy of CustomEventData from OES/script, because it cant be imported here and also needs to be defined in OES
122#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
123pub struct EssencesCustomEventData(pub BTreeMap<String, i64>);
124
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
126pub enum EntityAction {
127    CastEffect {
128        entity_id: Uuid,
129        effect_id: Uuid,
130    },
131    CastAbility {
132        ability_id: AbilityId,
133        target_entity_id: EntityId,
134    },
135    CastBasicAbility {
136        ability_id: AbilityId,
137        target_entity_id: EntityId,
138    },
139    StartCastAbility {
140        ability_id: AbilityId,
141        by_entity_id: EntityId,
142        pet_id: Option<PetId>,
143    },
144}
145
146impl EntityAction {
147    fn get_action_priority(action: &EntityAction) -> ActionPriority {
148        match action {
149            EntityAction::CastEffect { .. } => ActionPriority::First,
150            EntityAction::CastAbility { .. } => ActionPriority::Second,
151            EntityAction::CastBasicAbility { .. } => ActionPriority::Third,
152            EntityAction::StartCastAbility { .. } => ActionPriority::Fourth,
153        }
154    }
155
156    fn get_actions_priorities(actions: &[Self]) -> Vec<ActionPriority> {
157        actions.iter().map(Self::get_action_priority).collect()
158    }
159
160    fn get_casting_spell_priorities() -> Vec<ActionPriority> {
161        Self::get_actions_priorities(&[
162            Self::CastAbility {
163                ability_id: Uuid::nil(),
164                target_entity_id: Uuid::nil(),
165            },
166            Self::CastBasicAbility {
167                ability_id: Uuid::nil(),
168                target_entity_id: Uuid::nil(),
169            },
170        ])
171    }
172
173    pub fn get_cast_ability_priority() -> ActionPriority {
174        Self::get_action_priority(&Self::CastAbility {
175            ability_id: Uuid::nil(),
176            target_entity_id: Uuid::nil(),
177        })
178    }
179
180    pub fn get_cast_basic_ability_priority() -> ActionPriority {
181        Self::get_action_priority(&Self::CastBasicAbility {
182            ability_id: Uuid::nil(),
183            target_entity_id: Uuid::nil(),
184        })
185    }
186
187    pub fn get_starting_cast_priority() -> ActionPriority {
188        Self::get_action_priority(&Self::StartCastAbility {
189            ability_id: Uuid::nil(),
190            by_entity_id: Uuid::nil(),
191            pet_id: None,
192        })
193    }
194
195    pub fn get_cast_effect_priority() -> ActionPriority {
196        Self::get_action_priority(&Self::CastEffect {
197            entity_id: Uuid::nil(),
198            effect_id: Uuid::nil(),
199        })
200    }
201}
202
203/// Scales a base cooldown duration (in ticks, defined at baseline speed) by the entity's current
204/// speed attribute. Returns the cooldown duration the entity actually experiences.
205///
206/// `scaled = base * baseline_speed / max(speed, 1)`. A speed of `0` or negative is treated as
207/// `baseline_speed`, so unmigrated units keep working at 1×.
208///
209/// Floors at 1 tick when `base > 0` so high speeds never collapse a cooldown to zero via integer
210/// truncation (which would change semantics from "very fast" to "off cooldown forever").
211///
212/// `baseline_speed` is sourced from `GameSettings.baseline_speed`.
213pub fn scale_cooldown_for_speed(base_cooldown_ticks: u64, speed: i64, baseline_speed: u64) -> u64 {
214    if base_cooldown_ticks == 0 {
215        return 0;
216    }
217    let baseline = baseline_speed.max(1);
218    let effective_speed = if speed <= 0 { baseline } else { speed as u64 };
219    let scaled = (base_cooldown_ticks as u128 * baseline as u128) / effective_speed as u128;
220    (scaled as u64).max(1)
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
224pub struct ActionWithDeadline {
225    pub action: EntityAction,
226    pub deadline_tick: u64,
227    /// Provenance the rebuilt event inherits when this action drains.
228    ///
229    /// The queue is a real hop in a cascade: a modifier arms an interval effect
230    /// or drives a cast, and the work comes back one or more ticks later, after
231    /// the producing dispatch is long gone. Without this field
232    /// `entities::event_from_entity_action` would rebuild it as Core and a
233    /// modifier's output would re-enter the triggers that produced it.
234    ///
235    /// Fight state is never persisted (`active_fight` hydrates as `None`), so
236    /// this field is state-shape only — no migration.
237    pub origin: CombatEventOrigin,
238}
239
240impl ActionWithDeadline {
241    /// An action the fight engine itself queues: the entity's own cadence.
242    pub fn core(action: EntityAction, deadline_tick: u64) -> Self {
243        Self {
244            action,
245            deadline_tick,
246            origin: CombatEventOrigin::Core,
247        }
248    }
249}
250
251#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
252pub struct EntityActionsQueue {
253    action_queues: HashMap<ActionPriority, Vec<ActionWithDeadline>>,
254    entity_id: EntityId,
255}
256
257impl EntityActionsQueue {
258    pub fn new(entity_id: EntityId) -> Self {
259        Self {
260            action_queues: HashMap::new(),
261            entity_id,
262        }
263    }
264
265    // Push signle action
266    pub fn push(&mut self, action_with_deadline: &ActionWithDeadline) {
267        let priority = EntityAction::get_action_priority(&action_with_deadline.action);
268
269        self.action_queues
270            .entry(priority)
271            .or_default()
272            .push(action_with_deadline.clone());
273    }
274
275    // Add StartCastAbilityAction, depending on what action was triggered
276    fn add_start_cast_ability(
277        &mut self,
278        action_with_deadline: Option<&ActionWithDeadline>,
279        current_tick: u64,
280        ability_id: AbilityId,
281        ability_cooldown: u64,
282    ) {
283        if let Some(action_with_deadline) = action_with_deadline {
284            match action_with_deadline.action {
285                EntityAction::CastAbility { .. } | EntityAction::CastBasicAbility { .. } => {
286                    if ability_cooldown != 0 {
287                        self.push_start_cast_replacing(ability_id, current_tick + ability_cooldown)
288                    }
289                }
290                EntityAction::StartCastAbility { .. } => {}
291                EntityAction::CastEffect { .. } => {}
292            }
293        } else {
294            self.push_start_cast_replacing(ability_id, current_tick);
295        }
296    }
297
298    // Append multiple actions(casts or move) + add start_cast_ability, depending on action
299    pub fn append_start_cast_ability_result_actions(
300        &mut self,
301        actions_with_deadlines: &Vec<ActionWithDeadline>,
302        current_tick: u64,
303        ability_id: AbilityId,
304        ability_cooldown: u64,
305    ) {
306        for action_with_deadline in actions_with_deadlines {
307            self.push(action_with_deadline);
308        }
309
310        // TODO
311        // If move -> actions_with_deadlines should be empty -> Add StartCastAbility without increased deadline. If CastAbility -> increase deadline by ability cooldown.
312        self.add_start_cast_ability(
313            actions_with_deadlines.first(),
314            current_tick,
315            ability_id,
316            ability_cooldown,
317        );
318    }
319
320    fn is_casting_spell(&self) -> bool {
321        for priority in EntityAction::get_casting_spell_priorities() {
322            if let Some(queue) = self.action_queues.get(&priority)
323                && !queue.is_empty()
324            {
325                return true;
326            }
327        }
328        false
329    }
330
331    fn check_action_is_available(&self, action_priority: &ActionPriority) -> bool {
332        match action_priority {
333            ActionPriority::First => true,
334            ActionPriority::Second => true,
335            ActionPriority::Third => true,
336            ActionPriority::Fourth => !self.is_casting_spell(),
337        }
338    }
339
340    /// Drains the next due action, provenance included — the rebuilt event
341    /// inherits [`ActionWithDeadline::origin`] instead of resetting to Core.
342    pub fn pop(&mut self, current_tick: u64) -> Option<ActionWithDeadline> {
343        for priority in ActionPriority::iter() {
344            if !self.check_action_is_available(&priority) {
345                continue;
346            }
347
348            if let Some(queue) = self.action_queues.get_mut(&priority)
349                && let Some((idx, action_with_deadline)) = queue
350                    .iter()
351                    .enumerate()
352                    .min_by_key(|(_, action_with_deadline)| action_with_deadline.deadline_tick)
353                && action_with_deadline.deadline_tick <= current_tick
354            {
355                return Some(queue.remove(idx));
356            }
357        }
358
359        None
360    }
361
362    pub fn remove_start_cast_ability_action(&mut self, ability_id_to_remove: AbilityId) {
363        if let Some(queue) = self.action_queues.get_mut(&EntityAction::get_starting_cast_priority()) && let Some(pos) = queue.iter().position(|action_with_deadline| {
364            matches!(
365                action_with_deadline.action,
366                EntityAction::StartCastAbility { ability_id, .. } if ability_id == ability_id_to_remove
367            )
368        }) {
369            queue.remove(pos);
370        }
371    }
372
373    pub fn remove_cast_effect_action(&mut self, effect_id_to_remove: EffectId) {
374        if let Some(queue) = self
375            .action_queues
376            .get_mut(&EntityAction::get_cast_effect_priority())
377            && let Some(pos) = queue.iter().position(|action_with_deadline| {
378                matches!(
379                    action_with_deadline.action,
380                    EntityAction::CastEffect { effect_id, .. } if effect_id == effect_id_to_remove
381                )
382            })
383        {
384            queue.remove(pos);
385        }
386    }
387
388    pub fn get_closest_start_cast_action_deadline(&self) -> Option<u64> {
389        if let Some(queue) = self
390            .action_queues
391            .get(&EntityAction::get_starting_cast_priority())
392        {
393            return queue.iter().map(|action| action.deadline_tick).min();
394        }
395
396        None
397    }
398
399    /// Rescales every `StartCastAbility` (cooldown) entry in the queue to reflect a change in
400    /// the entity's speed attribute.
401    ///
402    /// At speed `S`, a cooldown that was originally `C` ticks long elapses in `C * baseline / S`
403    /// game-ticks. So when speed changes from `S_old` to `S_new`, the still-remaining game-ticks
404    /// for each in-flight cooldown become `(deadline - current_tick) * S_old / S_new`.
405    /// `baseline_speed` falls in for non-positive speed values.
406    ///
407    /// In-flight casts (`CastAbility` / `CastBasicAbility` cast animations) are intentionally not
408    /// touched here — speed scales cooldowns, not cast time.
409    pub fn rescale_cooldowns(
410        &mut self,
411        old_speed: i64,
412        new_speed: i64,
413        current_tick: u64,
414        baseline_speed: u64,
415    ) {
416        if old_speed == new_speed {
417            return;
418        }
419        let baseline = baseline_speed.max(1);
420        let old_speed = if old_speed <= 0 {
421            baseline
422        } else {
423            old_speed as u64
424        };
425        let new_speed = if new_speed <= 0 {
426            baseline
427        } else {
428            new_speed as u64
429        };
430
431        let Some(queue) = self
432            .action_queues
433            .get_mut(&EntityAction::get_starting_cast_priority())
434        else {
435            return;
436        };
437
438        for action in queue.iter_mut() {
439            if !matches!(action.action, EntityAction::StartCastAbility { .. }) {
440                continue;
441            }
442            let remaining = action.deadline_tick.saturating_sub(current_tick);
443            if remaining == 0 {
444                continue;
445            }
446            let scaled = (remaining as u128 * old_speed as u128) / new_speed as u128;
447            let scaled = (scaled as u64).max(1);
448            action.deadline_tick = current_tick + scaled;
449        }
450    }
451
452    /// Applies stun semantics to a single ability:
453    /// - If the ability is currently mid-cast (`CastAbility`/`CastBasicAbility` queued), cancels
454    ///   the cast and sets the cooldown deadline to `current_tick + full_cooldown_ticks +
455    ///   duration_ticks` — full cooldown plus the stun freeze on top.
456    /// - Otherwise, if the ability already has a cooldown entry, extends its deadline by
457    ///   `duration_ticks` (the cooldown effectively pauses for the stun duration).
458    /// - Otherwise (off cooldown), pushes a fresh cooldown entry of `duration_ticks` so the
459    ///   ability stays unusable while stunned.
460    ///
461    /// `full_cooldown_ticks` is the ability's full cooldown (already scaled for entity speed if
462    /// the caller wants speed to apply).
463    /// Push the cooldown entry (`StartCastAbility`) for `ability_id`, replacing any entry
464    /// already queued for it and keeping the LATER deadline. One ability must never hold two
465    /// self-rescheduling cooldown entries: a stun landing on the exact tick the previous entry
466    /// was popped (event in flight) used to add a second one, permanently doubling the
467    /// ability's attack loop.
468    pub fn push_start_cast_replacing(&mut self, ability_id: AbilityId, deadline_tick: u64) {
469        let existing_max = self.start_cast_deadlines(ability_id).into_iter().max();
470        if let Some(queue) = self
471            .action_queues
472            .get_mut(&EntityAction::get_starting_cast_priority())
473        {
474            queue.retain(|a| {
475                !matches!(
476                    a.action,
477                    EntityAction::StartCastAbility { ability_id: aid, .. } if aid == ability_id
478                )
479            });
480        }
481        let entity_id = self.entity_id;
482        // Cooldown entries are the entity's own cadence, not any producer's
483        // output: the same metronome would have re-armed with or without a
484        // modifier, and its rate is bounded by the ability cooldown. Marking it
485        // would silently turn every later swing of that ability into a
486        // modifier's product and switch triggers off for the rest of the fight.
487        self.push(&ActionWithDeadline::core(
488            EntityAction::StartCastAbility {
489                ability_id,
490                by_entity_id: entity_id,
491                pet_id: None,
492            },
493            existing_max.map_or(deadline_tick, |d| d.max(deadline_tick)),
494        ));
495    }
496
497    /// Every queued cooldown entry as `(ability_id, deadline_tick)`.
498    ///
499    /// The read counterpart of [`Self::adjust_ability_cooldown`]: a consumer
500    /// that wants "the longest remaining cooldown" (laws `RL-03`, `RL-11`,
501    /// `FL-04`, `FL-11`) needs the whole set, not one ability's deadlines.
502    pub fn start_cast_entries(&self) -> Vec<(AbilityId, u64)> {
503        self.action_queues
504            .get(&EntityAction::get_starting_cast_priority())
505            .map(|queue| {
506                queue
507                    .iter()
508                    .filter_map(|a| match a.action {
509                        EntityAction::StartCastAbility { ability_id, .. } => {
510                            Some((ability_id, a.deadline_tick))
511                        }
512                        _ => None,
513                    })
514                    .collect()
515            })
516            .unwrap_or_default()
517    }
518
519    /// Remaining cooldown of every ability that is not ready yet, longest
520    /// first. Ties break on `ability_id` so the order is deterministic.
521    ///
522    /// A `StartCastAbility` entry IS the ability's cooldown: it is the
523    /// self-rescheduling metronome that lets the ability swing again at
524    /// `deadline_tick`. That is why the three helpers below are enough to
525    /// express every "shorten / reset a Skill cooldown" Effect Stone without
526    /// any new state.
527    pub fn ability_cooldowns(&self, current_tick: u64) -> Vec<(AbilityId, u64)> {
528        let Some(queue) = self
529            .action_queues
530            .get(&EntityAction::get_starting_cast_priority())
531        else {
532            return Vec::new();
533        };
534        let mut remaining: Vec<(AbilityId, u64)> = queue
535            .iter()
536            .filter_map(|action| match action.action {
537                EntityAction::StartCastAbility { ability_id, .. } => {
538                    let left = action.deadline_tick.saturating_sub(current_tick);
539                    (left > 0).then_some((ability_id, left))
540                }
541                _ => None,
542            })
543            .collect();
544        remaining.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
545        remaining
546    }
547
548    /// Brings EVERY remaining cooldown of the named abilities forward by
549    /// `by_ticks` and returns how many entries actually moved.
550    ///
551    /// The named-set sibling of [`Self::shorten_longest_cooldowns`], which takes
552    /// the `count` longest ones instead. `PET-01 Head Start` needs this shape:
553    /// its wording is "ALL remaining Skill cooldowns", and the caller — not this
554    /// queue, which cannot tell a Skill from a class Basic Attack — decides
555    /// which abilities are Skills.
556    pub fn shorten_cooldowns_of(
557        &mut self,
558        current_tick: u64,
559        ability_ids: &[AbilityId],
560        by_ticks: u64,
561    ) -> usize {
562        if ability_ids.is_empty() || by_ticks == 0 {
563            return 0;
564        }
565        let Some(queue) = self
566            .action_queues
567            .get_mut(&EntityAction::get_starting_cast_priority())
568        else {
569            return 0;
570        };
571        let mut moved = 0;
572        for action in queue.iter_mut() {
573            let EntityAction::StartCastAbility { ability_id, .. } = action.action else {
574                continue;
575            };
576            if !ability_ids.contains(&ability_id) || action.deadline_tick <= current_tick {
577                continue;
578            }
579            action.deadline_tick = action
580                .deadline_tick
581                .saturating_sub(by_ticks)
582                .max(current_tick);
583            moved += 1;
584        }
585        moved
586    }
587
588    /// Brings the `count` longest remaining cooldowns forward by `by_ticks` and
589    /// returns how many entries actually moved.
590    ///
591    /// A cooldown is never pushed before `current_tick`: the ability becomes
592    /// ready, it does not become ready in the past.
593    pub fn shorten_longest_cooldowns(
594        &mut self,
595        current_tick: u64,
596        count: usize,
597        by_ticks: u64,
598    ) -> usize {
599        if count == 0 || by_ticks == 0 {
600            return 0;
601        }
602        let chosen: Vec<AbilityId> = self
603            .ability_cooldowns(current_tick)
604            .into_iter()
605            .take(count)
606            .map(|(ability_id, _)| ability_id)
607            .collect();
608        if chosen.is_empty() {
609            return 0;
610        }
611        let Some(queue) = self
612            .action_queues
613            .get_mut(&EntityAction::get_starting_cast_priority())
614        else {
615            return 0;
616        };
617        let mut moved = 0;
618        for action in queue.iter_mut() {
619            let EntityAction::StartCastAbility { ability_id, .. } = action.action else {
620                continue;
621            };
622            if !chosen.contains(&ability_id) || action.deadline_tick <= current_tick {
623                continue;
624            }
625            action.deadline_tick = action
626                .deadline_tick
627                .saturating_sub(by_ticks)
628                .max(current_tick);
629            moved += 1;
630        }
631        moved
632    }
633
634    /// Makes every ability ready at `current_tick` and returns how many
635    /// cooldowns were cleared.
636    pub fn clear_ability_cooldowns(&mut self, current_tick: u64) -> usize {
637        let Some(queue) = self
638            .action_queues
639            .get_mut(&EntityAction::get_starting_cast_priority())
640        else {
641            return 0;
642        };
643        let mut cleared = 0;
644        for action in queue.iter_mut() {
645            if !matches!(action.action, EntityAction::StartCastAbility { .. })
646                || action.deadline_tick <= current_tick
647            {
648                continue;
649            }
650            action.deadline_tick = current_tick;
651            cleared += 1;
652        }
653        cleared
654    }
655
656    /// Deadlines of every `StartCastAbility` entry queued for `ability_id`.
657    /// Load-bearing: `push_start_cast_replacing` uses the max to never shorten
658    /// an existing (e.g. stun-extended) cooldown entry.
659    pub fn start_cast_deadlines(&self, ability_id: AbilityId) -> Vec<u64> {
660        self.action_queues
661            .get(&EntityAction::get_starting_cast_priority())
662            .map(|queue| {
663                queue
664                    .iter()
665                    .filter(|a| {
666                        matches!(
667                            a.action,
668                            EntityAction::StartCastAbility { ability_id: aid, .. } if aid == ability_id
669                        )
670                    })
671                    .map(|a| a.deadline_tick)
672                    .collect()
673            })
674            .unwrap_or_default()
675    }
676
677    pub fn stun_ability(
678        &mut self,
679        ability_id: AbilityId,
680        duration_ticks: u64,
681        full_cooldown_ticks: u64,
682        current_tick: u64,
683    ) {
684        let had_in_flight_cast = [
685            EntityAction::get_cast_ability_priority(),
686            EntityAction::get_cast_basic_ability_priority(),
687        ]
688        .iter()
689        .any(|priority| {
690            self.action_queues
691                .get(priority)
692                .is_some_and(|queue| {
693                    queue.iter().any(|action_with_deadline| {
694                        matches!(
695                            action_with_deadline.action,
696                            EntityAction::CastAbility { ability_id: aid, .. } | EntityAction::CastBasicAbility { ability_id: aid, .. } if aid == ability_id
697                        )
698                    })
699                })
700        });
701
702        if had_in_flight_cast {
703            self.cancel_cast_and_set_cooldown(
704                ability_id,
705                current_tick
706                    .saturating_add(full_cooldown_ticks)
707                    .saturating_add(duration_ticks),
708            );
709            return;
710        }
711
712        if self.adjust_ability_cooldown(ability_id, duration_ticks as i64, current_tick) {
713            return;
714        }
715
716        // No existing cooldown — push a fresh stun-only cooldown (the entry was
717        // popped this same tick; the in-flight event is handled by the
718        // `stun_until_tick` guard in `logic/fighting.rs`).
719        let entity_id = self.entity_id;
720        // Cadence entry — see `push_start_cast_replacing`.
721        self.push(&ActionWithDeadline::core(
722            EntityAction::StartCastAbility {
723                ability_id,
724                by_entity_id: entity_id,
725                pet_id: None,
726            },
727            current_tick.saturating_add(duration_ticks),
728        ));
729    }
730
731    /// Cancels any in-flight cast (CastAbility / CastBasicAbility) for `ability_id_to_cancel`
732    /// and replaces the cooldown entry (StartCastAbility) with a new one at `new_deadline_tick`.
733    /// Returns `true` when an in-flight cast was found and removed.
734    pub fn cancel_cast_and_set_cooldown(
735        &mut self,
736        ability_id_to_cancel: AbilityId,
737        new_deadline_tick: u64,
738    ) -> bool {
739        let mut had_in_flight = false;
740        for priority in [
741            EntityAction::get_cast_ability_priority(),
742            EntityAction::get_cast_basic_ability_priority(),
743        ] {
744            if let Some(queue) = self.action_queues.get_mut(&priority) {
745                let original_len = queue.len();
746                queue.retain(|action_with_deadline| {
747                    !matches!(
748                        action_with_deadline.action,
749                        EntityAction::CastAbility { ability_id, .. } if ability_id == ability_id_to_cancel
750                    ) && !matches!(
751                        action_with_deadline.action,
752                        EntityAction::CastBasicAbility { ability_id, .. } if ability_id == ability_id_to_cancel
753                    )
754                });
755                if queue.len() != original_len {
756                    had_in_flight = true;
757                }
758            }
759        }
760
761        if let Some(queue) = self
762            .action_queues
763            .get_mut(&EntityAction::get_starting_cast_priority())
764        {
765            queue.retain(|action_with_deadline| {
766                !matches!(
767                    action_with_deadline.action,
768                    EntityAction::StartCastAbility { ability_id, .. } if ability_id == ability_id_to_cancel
769                )
770            });
771        }
772
773        let entity_id = self.entity_id;
774        // Cadence entry — see `push_start_cast_replacing`.
775        self.push(&ActionWithDeadline::core(
776            EntityAction::StartCastAbility {
777                ability_id: ability_id_to_cancel,
778                by_entity_id: entity_id,
779                pet_id: None,
780            },
781            new_deadline_tick,
782        ));
783
784        had_in_flight
785    }
786
787    /// Adjusts the cooldown for a specific ability by `delta_ticks`.
788    /// Positive delta extends the cooldown, negative shortens it (saturating at `current_tick`,
789    /// i.e. no remaining cooldown). Returns `true` when an entry was found and adjusted.
790    pub fn adjust_ability_cooldown(
791        &mut self,
792        ability_id_to_adjust: AbilityId,
793        delta_ticks: i64,
794        current_tick: u64,
795    ) -> bool {
796        let Some(queue) = self
797            .action_queues
798            .get_mut(&EntityAction::get_starting_cast_priority())
799        else {
800            return false;
801        };
802
803        let Some(action) = queue.iter_mut().find(|action_with_deadline| {
804            matches!(
805                action_with_deadline.action,
806                EntityAction::StartCastAbility { ability_id, .. } if ability_id == ability_id_to_adjust
807            )
808        }) else {
809            return false;
810        };
811
812        action.deadline_tick = if delta_ticks >= 0 {
813            action.deadline_tick.saturating_add(delta_ticks as u64)
814        } else {
815            let abs = (-delta_ticks) as u64;
816            action.deadline_tick.saturating_sub(abs).max(current_tick)
817        };
818
819        true
820    }
821
822    pub fn view(&self) -> HashMap<ActionPriority, Vec<ActionWithDeadline>> {
823        self.action_queues.clone()
824    }
825}
826
827#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
828#[tsify(from_wasm_abi, into_wasm_abi)]
829pub struct Entity {
830    pub id: EntityId,
831    pub max_hp: u64,
832    pub hp: u64,
833    pub abilities: Vec<ActiveAbility>,
834    #[schemars(skip)]
835    pub actions_queue: EntityActionsQueue,
836    pub attributes: EntityAttributes,
837    pub effect_ids: Vec<EffectId>,
838    pub coordinates: Coordinates,
839    /// Destination of the in-flight run, `None` when not moving. Also reserves
840    /// the cell so a concurrently-planning opponent will not run onto it.
841    pub move_target: Option<Coordinates>,
842    pub width: i8, // not ENUM because JsonSchema and Tsify can't be friends // TODO I am not sure anymore
843    pub rewards: Option<Vec<EnemyReward>>,
844    pub class_id: Option<ClassId>,
845    pub team: EntityTeam,
846    pub has_big_hp_bar: bool,
847    pub entity_template_id: Option<EntityTemplateId>,
848    /// Fight-local copy of this hero's global equipment-side state. Character
849    /// combatants carry `Some`; non-player entities carry `None`.
850    #[serde(default)]
851    pub flip_state: Option<crate::flip::FlipState>,
852    /// Fight-local mana. Character combatants carry `Some` (full at fight
853    /// start, never carried between fights); mobs carry `None` and are never
854    /// mana-gated.
855    pub mana: Option<crate::mana::ManaPool>,
856    /// Per-proc-type entropy accumulators (PoE-style deterministic proc
857    /// resolution): key = stat code,
858    /// value = accumulator in permyriad (proc when it crosses 10000). Combat
859    /// runtime only: `serde(skip)` keeps it off the wire/DB/Unity schema
860    /// (postcard is positional — a serialized field would break the wire);
861    /// it resets on reconnect, like PoE resets entropy after idle.
862    #[serde(skip)]
863    #[schemars(skip)]
864    pub proc_entropy: EntropyCell,
865    /// Live bridge charges of THIS combatant (twin cores, OVT-2517, laws
866    /// v0.2). Combat runtime only, same treatment as `proc_entropy`:
867    /// `serde(skip)` keeps it off the wire, the DB and the Unity schema.
868    /// `active_fight` is never persisted, so there is no durable copy that
869    /// could drift; a reconnect starts a fresh fight and therefore an empty
870    /// charge, which is exactly what "the charge resets between fights" already
871    /// prescribes.
872    #[serde(skip)]
873    #[schemars(skip)]
874    pub law_bridges: crate::cores::LawBridgeCharges,
875    /// Cores snapshot THIS combatant fights with, taken when the entity was
876    /// built (`entities::seed_law_state`). Everything the law layer needs
877    /// mid-fight — which laws are slotted, at what level, on which side — is
878    /// read from here, so the local hero, the party ally and a PvP opponent all
879    /// take the same entity-local path instead of the session-state lookup that
880    /// only ever worked for the first two.
881    ///
882    /// Empty for mobs and for a combatant whose cores are not unlocked yet, and
883    /// then every law computation degenerates to "no contribution".
884    ///
885    /// A snapshot rather than a live borrow on purpose: Core build edits made
886    /// during combat update durable character state, so this fight intentionally
887    /// keeps the Core values captured at start and the new Core build applies to
888    /// the next fight. This also keeps a party-ally refresh
889    /// from swapping the ally's laws out from under attributes that were
890    /// already folded in.
891    ///
892    /// Combat runtime only, same treatment as `law_bridges`.
893    #[serde(skip)]
894    #[schemars(skip)]
895    pub law_cores: crate::cores::CoresState,
896}
897
898/// Sync interior-mutable holder for [`Entity::proc_entropy`] — proc checks run
899/// behind `&Entity` throughout the fight code, and `OverlordState` futures
900/// must stay `Send` (a `RefCell` here would un-`Sync` the whole state).
901/// Manual impls because `Mutex` is neither `Clone` nor `Eq`; equality/clone
902/// go through snapshots (combat-transient data, never serialized).
903#[derive(Debug, Default)]
904pub struct EntropyCell(std::sync::Mutex<std::collections::HashMap<String, i64>>);
905
906impl EntropyCell {
907    /// Advance the `key` accumulator by `add` permyriad (seeding it with
908    /// `init` on first touch) and report whether it crossed 10000 — the
909    /// entropy proc: exactly one proc per 10000 permyriad accumulated, no
910    /// streaks either way.
911    pub fn bump(&self, key: &str, add: i64, init: impl FnOnce() -> i64) -> bool {
912        let mut m = self.0.lock().unwrap();
913        let acc = m.entry(key.to_string()).or_insert_with(init);
914        *acc += add;
915        if *acc >= 10000 {
916            *acc -= 10000;
917            true
918        } else {
919            false
920        }
921    }
922}
923
924impl Clone for EntropyCell {
925    fn clone(&self) -> Self {
926        Self(std::sync::Mutex::new(self.0.lock().unwrap().clone()))
927    }
928}
929
930impl PartialEq for EntropyCell {
931    fn eq(&self, other: &Self) -> bool {
932        *self.0.lock().unwrap() == *other.0.lock().unwrap()
933    }
934}
935
936impl Eq for EntropyCell {}
937
938#[derive(Debug, Clone, Eq, PartialEq)]
939pub enum EntityState<'a> {
940    Character(&'a CharacterState),
941    Opponent(&'a OpponentState),
942}
943
944impl<'a> EntityState<'a> {
945    pub fn id(&self) -> uuid::Uuid {
946        match self {
947            EntityState::Character(character_state) => character_state.character.id,
948            EntityState::Opponent(opponent_state) => opponent_state.id(),
949        }
950    }
951
952    pub fn level(&self) -> i64 {
953        match self {
954            EntityState::Character(character_state) => character_state.character.character_level,
955            EntityState::Opponent(opponent_state) => opponent_state.level(),
956        }
957    }
958
959    pub fn current_chapter_level(&self) -> i64 {
960        match self {
961            EntityState::Character(character_state) => {
962                character_state.character.current_chapter_level
963            }
964            EntityState::Opponent(OpponentState::Human(human)) => {
965                human.character_state.character.current_chapter_level
966            }
967            EntityState::Opponent(OpponentState::Bot(bot)) => bot.bot.level,
968        }
969    }
970
971    pub fn inventory(&self) -> &Vec<Item> {
972        match self {
973            EntityState::Character(character_state) => &character_state.inventory,
974            EntityState::Opponent(opponent_state) => opponent_state.inventory(),
975        }
976    }
977
978    pub fn class(&self) -> ClassId {
979        match self {
980            EntityState::Character(character_state) => character_state.character.class,
981            EntityState::Opponent(opponent_state) => opponent_state.class(),
982        }
983    }
984
985    pub fn equipped_abilities(&self) -> &EquippedAbilities {
986        match self {
987            EntityState::Character(character_state) => &character_state.equipped_abilities,
988            EntityState::Opponent(opponent_state) => opponent_state.equipped_abilities(),
989        }
990    }
991
992    pub fn equipped_pets(&self) -> Option<&EquippedPets> {
993        match self {
994            EntityState::Character(character_state) => Some(&character_state.equipped_pets),
995            EntityState::Opponent(opponent_state) => opponent_state.equipped_pets(),
996        }
997    }
998
999    /// Twin-cores state of whoever this combatant is (OVT-2517). A human PvP
1000    /// opponent carries a full `CharacterState`, so their cores are just as
1001    /// available as the local hero's; an arena filler bot has none.
1002    pub fn cores(&self) -> Option<&crate::cores::CoresState> {
1003        match self {
1004            EntityState::Character(character_state) => Some(&character_state.cores),
1005            EntityState::Opponent(opponent_state) => {
1006                opponent_state.character_state().map(|state| &state.cores)
1007            }
1008        }
1009    }
1010
1011    /// Researched talent levels of whoever this combatant is. Same shape as
1012    /// [`Self::cores`]: a human PvP opponent carries a full `CharacterState`, so
1013    /// their talents are as available as the local hero's; an arena filler bot
1014    /// has no talent tree and returns `None`.
1015    pub fn talent_levels(&self) -> Option<&crate::talent_tree::TalentLevelsMap> {
1016        match self {
1017            EntityState::Character(character_state) => Some(&character_state.talent_levels),
1018            EntityState::Opponent(opponent_state) => opponent_state
1019                .character_state()
1020                .map(|state| &state.talent_levels),
1021        }
1022    }
1023
1024    /// Statue state of whoever this combatant is. `None` for arena filler bots,
1025    /// which have no statue.
1026    pub fn statue_state(&self) -> Option<&crate::statue::StatueState> {
1027        match self {
1028            EntityState::Character(character_state) => Some(&character_state.statue_state),
1029            EntityState::Opponent(opponent_state) => opponent_state
1030                .character_state()
1031                .map(|state| &state.statue_state),
1032        }
1033    }
1034
1035    /// Per-class progression rows of whoever this combatant is. `None` for arena
1036    /// filler bots, which have no class levels (their `class()` still resolves —
1037    /// only the levelling progress is absent).
1038    pub fn character_classes(&self) -> Option<&[crate::class::CharacterClass]> {
1039        match self {
1040            EntityState::Character(character_state) => Some(&character_state.character_classes),
1041            EntityState::Opponent(opponent_state) => opponent_state
1042                .character_state()
1043                .map(|state| state.character_classes.as_slice()),
1044        }
1045    }
1046
1047    /// Gear-stone inventory of whoever this combatant is. Same shape as
1048    /// [`Self::cores`]: a human PvP opponent carries a full `CharacterState`,
1049    /// so their socketed stones count like the local hero's; an arena filler
1050    /// bot has none.
1051    pub fn stones(&self) -> Option<&crate::stones::StoneInventory> {
1052        match self {
1053            EntityState::Character(character_state) => Some(&character_state.stones),
1054            EntityState::Opponent(opponent_state) => {
1055                opponent_state.character_state().map(|state| &state.stones)
1056            }
1057        }
1058    }
1059
1060    /// Ability-stone collection of whoever this combatant is — the OWNED
1061    /// stones, socketed or not, which is what Collection Power reads. `None`
1062    /// for arena filler bots.
1063    pub fn ability_stones(&self) -> Option<&[crate::ability_stones::OwnedAbilityStone]> {
1064        match self {
1065            EntityState::Character(character_state) => {
1066                Some(character_state.ability_stones.as_slice())
1067            }
1068            EntityState::Opponent(opponent_state) => opponent_state
1069                .character_state()
1070                .map(|state| state.ability_stones.as_slice()),
1071        }
1072    }
1073
1074    /// Every ability this combatant owns, equipped or not. Distinct from
1075    /// [`Self::equipped_abilities`], which is the loadout — Collection Power
1076    /// pays for the collection, so it needs the whole roster. `None` for arena
1077    /// filler bots, whose kit is authored rather than collected.
1078    pub fn all_abilities(&self) -> Option<&[crate::abilities::Ability]> {
1079        match self {
1080            EntityState::Character(character_state) => {
1081                Some(character_state.all_abilities.as_slice())
1082            }
1083            EntityState::Opponent(opponent_state) => opponent_state
1084                .character_state()
1085                .map(|state| state.all_abilities.as_slice()),
1086        }
1087    }
1088
1089    /// Every pet this combatant owns, slotted or not — the counterpart of
1090    /// [`Self::equipped_pets`] for the same reason [`Self::all_abilities`] is
1091    /// the counterpart of the loadout. `None` for arena filler bots.
1092    pub fn all_pets(&self) -> Option<&[crate::pets::Pet]> {
1093        match self {
1094            EntityState::Character(character_state) => Some(character_state.all_pets.as_slice()),
1095            EntityState::Opponent(opponent_state) => opponent_state
1096                .character_state()
1097                .map(|state| state.all_pets.as_slice()),
1098        }
1099    }
1100
1101    /// Artifact collection of whoever this combatant is. `None` for arena
1102    /// filler bots, which own no artifacts.
1103    pub fn artifacts(&self) -> Option<&crate::artifacts::ArtifactCollection> {
1104        match self {
1105            EntityState::Character(character_state) => Some(&character_state.artifacts),
1106            EntityState::Opponent(opponent_state) => opponent_state
1107                .character_state()
1108                .map(|state| &state.artifacts),
1109        }
1110    }
1111
1112    /// Banked Plinko pin bonuses of whoever this combatant is. `None` for
1113    /// arena filler bots.
1114    pub fn plinko_pin_bonuses(&self) -> Option<&crate::plinko::PlinkoPinBonusesMap> {
1115        match self {
1116            EntityState::Character(character_state) => Some(&character_state.plinko_pin_bonuses),
1117            EntityState::Opponent(opponent_state) => opponent_state
1118                .character_state()
1119                .map(|state| &state.plinko_pin_bonuses),
1120        }
1121    }
1122}
1123
1124#[cfg(test)]
1125mod wave_share_tests {
1126    use super::*;
1127
1128    /// §2: `wave_share()` reads the per-10000 attribute as a fraction; absent ⇒
1129    /// 1.0 (legacy content), so every consumer defaults to no scaling.
1130    #[test]
1131    fn wave_share_reads_permyriad_or_defaults_to_one() {
1132        let mut attrs = EntityAttributes::default();
1133        assert_eq!(
1134            attrs.wave_share(),
1135            1.0,
1136            "absent wave_share must default to 1.0"
1137        );
1138
1139        attrs.add("wave_share", 5000);
1140        assert_eq!(attrs.wave_share(), 0.5);
1141
1142        attrs.set("wave_share", 10000);
1143        assert_eq!(attrs.wave_share(), 1.0);
1144
1145        attrs.set("wave_share", 3333);
1146        assert!((attrs.wave_share() - 0.3333).abs() < 1e-9);
1147    }
1148
1149    /// Boss-summon: a `summoned` mob has `wave_share() == 0` (no net faucet),
1150    /// overriding any stamped `wave_share` value, and `is_summoned()` is true.
1151    #[test]
1152    fn summoned_mob_has_zero_wave_share() {
1153        let mut attrs = EntityAttributes::default();
1154        assert!(!attrs.is_summoned());
1155
1156        attrs.add("summoned", 1);
1157        assert!(attrs.is_summoned());
1158        assert_eq!(
1159            attrs.wave_share(),
1160            0.0,
1161            "a summoned reinforcement contributes no drop/pet/counter faucet"
1162        );
1163
1164        // Even if a wave_share is also present, summoned wins (share stays 0).
1165        attrs.add("wave_share", 5000);
1166        assert_eq!(attrs.wave_share(), 0.0);
1167    }
1168}
1169
1170#[cfg(test)]
1171mod entropy_tests {
1172    use super::*;
1173
1174    /// The entropy accumulator delivers EXACTLY floor-rate procs: at 25%
1175    /// (2500 permyriad) every window of 4 checks contains exactly one proc —
1176    /// no lucky doubles, no dry streaks — for ANY seed. This is the whole
1177    /// point of the resolver: expected rate identical to independent rolls,
1178    /// variance of proc COUNT eliminated.
1179    #[test]
1180    fn entropy_rate_is_exact_for_any_seed() {
1181        for seed in [0i64, 1, 2499, 2500, 5000, 9999] {
1182            let cell = EntropyCell::default();
1183            let procs: Vec<bool> = (0..40).map(|_| cell.bump("crit", 2500, || seed)).collect();
1184            let total: usize = procs.iter().filter(|p| **p).count();
1185            assert_eq!(
1186                total, 10,
1187                "25% over 40 checks must proc exactly 10 (seed {seed})"
1188            );
1189            // no window of 4 consecutive checks has 2+ procs, none has 0
1190            for w in procs.windows(4) {
1191                let c = w.iter().filter(|p| **p).count();
1192                assert!(c <= 1, "double proc within a 4-window (seed {seed})");
1193            }
1194        }
1195    }
1196
1197    /// Different proc types keep independent accumulators on one entity.
1198    #[test]
1199    fn entropy_keys_are_independent() {
1200        let cell = EntropyCell::default();
1201        assert!(!cell.bump("a", 6000, || 0));
1202        assert!(cell.bump("a", 6000, || 0)); // 12000 → proc
1203        assert!(!cell.bump("b", 6000, || 0)); // separate key starts fresh
1204    }
1205}
1206
1207#[cfg(test)]
1208mod ability_cooldown_tests {
1209    use super::*;
1210
1211    fn queue_with(deadlines: &[(u128, u64)]) -> EntityActionsQueue {
1212        let mut queue = EntityActionsQueue::new(Uuid::from_u128(1));
1213        for (ability, deadline) in deadlines {
1214            queue.push_start_cast_replacing(Uuid::from_u128(*ability), *deadline);
1215        }
1216        queue
1217    }
1218
1219    /// The Effect Stones that shorten or clear a Skill cooldown all read this
1220    /// list, so its order is part of their contract: longest remaining first.
1221    #[test]
1222    fn cooldowns_are_reported_longest_first_and_ready_abilities_are_omitted() {
1223        let queue = queue_with(&[(1, 1_500), (2, 3_000), (3, 1_000)]);
1224
1225        let remaining = queue.ability_cooldowns(1_000);
1226
1227        assert_eq!(
1228            remaining,
1229            vec![(Uuid::from_u128(2), 2_000), (Uuid::from_u128(1), 500),],
1230            "ability 3 is already ready at tick 1000 and is not a cooldown"
1231        );
1232    }
1233
1234    /// `EF-C07` Quickstep takes 0.8 s off the longest cooldown — one, not all.
1235    #[test]
1236    fn shortening_touches_only_the_longest_cooldowns() {
1237        let mut queue = queue_with(&[(1, 2_000), (2, 5_000)]);
1238
1239        assert_eq!(queue.shorten_longest_cooldowns(0, 1, 800), 1);
1240
1241        assert_eq!(
1242            queue.ability_cooldowns(0),
1243            vec![(Uuid::from_u128(2), 4_200), (Uuid::from_u128(1), 2_000),],
1244            "only the longest one moved"
1245        );
1246    }
1247
1248    /// A cooldown becomes ready, never ready in the past — an over-long
1249    /// reduction clamps at "now" instead of pushing the deadline behind it.
1250    #[test]
1251    fn shortening_past_now_just_makes_the_ability_ready() {
1252        let mut queue = queue_with(&[(1, 1_200)]);
1253
1254        queue.shorten_longest_cooldowns(1_000, 1, 10_000);
1255
1256        assert!(
1257            queue.ability_cooldowns(1_000).is_empty(),
1258            "the ability is ready, and no deadline went behind the clock"
1259        );
1260        assert_eq!(queue.start_cast_deadlines(Uuid::from_u128(1)), vec![1_000]);
1261    }
1262
1263    /// `EF-L04` Time Collapse: every Skill becomes ready at once.
1264    #[test]
1265    fn clearing_makes_every_cooldown_ready() {
1266        let mut queue = queue_with(&[(1, 2_000), (2, 9_000), (3, 500)]);
1267
1268        assert_eq!(
1269            queue.clear_ability_cooldowns(1_000),
1270            2,
1271            "the two still on cooldown at tick 1000"
1272        );
1273        assert!(queue.ability_cooldowns(1_000).is_empty());
1274    }
1275
1276    /// Nothing on cooldown is a quiet no-op for both, not a panic.
1277    #[test]
1278    fn an_empty_queue_is_a_no_op() {
1279        let mut queue = EntityActionsQueue::new(Uuid::from_u128(1));
1280        assert!(queue.ability_cooldowns(0).is_empty());
1281        assert_eq!(queue.shorten_longest_cooldowns(0, 3, 500), 0);
1282        assert_eq!(queue.clear_ability_cooldowns(0), 0);
1283    }
1284}