essences/
abilities.rs

1use crate::prelude::*;
2
3use enum_iterator::Sequence;
4use strum_macros::{Display, EnumIter, EnumString};
5
6use std::collections::{BTreeMap, HashMap};
7
8#[declare]
9pub type AbilityId = Uuid;
10
11#[declare]
12pub type AbilitySlotId = usize;
13
14#[declare]
15pub type AbilityRarityId = Uuid;
16
17#[derive(
18    Debug,
19    Clone,
20    Copy,
21    EnumString,
22    Sequence,
23    Display,
24    Deserialize,
25    Serialize,
26    Hash,
27    Eq,
28    PartialEq,
29    EnumIter,
30    Default,
31    JsonSchema,
32    Tsify,
33)]
34#[tsify(namespace)]
35pub enum AbilityFightUiVisibility {
36    #[default]
37    Slotted,
38    Class,
39    Hidden,
40}
41
42impl AbilityFightUiVisibility {
43    pub fn is_player_equippable(&self) -> bool {
44        match self {
45            AbilityFightUiVisibility::Slotted => true,
46            AbilityFightUiVisibility::Class => false,
47            AbilityFightUiVisibility::Hidden => false,
48        }
49    }
50
51    pub fn is_slotted(&self) -> bool {
52        match self {
53            AbilityFightUiVisibility::Slotted => true,
54            AbilityFightUiVisibility::Class => false,
55            AbilityFightUiVisibility::Hidden => false,
56        }
57    }
58}
59
60/// A content-side label on an ability that other systems key off.
61///
62/// Read by the law catalog (`RL-06` / `FL-06` fire on "an original Skill with
63/// the `Heal` tag resolved") and by support stones (a support's
64/// `Compatibility` is a predicate over these tags). Declared with room:
65/// triggers and future systems are meant to read the SAME tag set rather than
66/// each inventing its own predicate over ability ids. Adding a variant is a
67/// config migration (every ability lists its tags explicitly — there is no
68/// default), not a code change at the reading sites.
69///
70/// Tags describe what an ability DOES, not how it is cast: `AbilityCastType` is
71/// presentation and `class.basic_abilities` is what decides Basic Attack vs
72/// original Skill.
73#[derive(
74    Debug,
75    Clone,
76    Copy,
77    EnumString,
78    Sequence,
79    Display,
80    Deserialize,
81    Serialize,
82    Hash,
83    Eq,
84    PartialEq,
85    Ord,
86    PartialOrd,
87    EnumIter,
88    Default,
89    JsonSchema,
90    Tsify,
91)]
92#[tsify(namespace)]
93pub enum AbilityTag {
94    /// Restores HP to the caster or an ally.
95    #[default]
96    Heal,
97    /// Deals direct damage.
98    Damage,
99    /// Hits an AREA: one payload spread over whatever stands in it.
100    Aoe,
101    /// Delivers its payload as SEVERAL copies (a volley of projectiles, a
102    /// flurry of cuts) rather than as one hit — `ability_info.projectiles > 1`.
103    /// Distinct from [`AbilityTag::Aoe`]: the copies are what Condense folds
104    /// into one, while `CoverageMult` (Widen) does not scale them.
105    MultiTarget,
106    /// Grants a positive timed state.
107    Buff,
108    /// Applies a negative timed state.
109    Debuff,
110    /// Stuns, roots or otherwise denies actions.
111    Control,
112    /// Belongs to a pet rather than to the character.
113    Pet,
114    /// Delivers its payload with a travelling projectile.
115    Projectile,
116    /// Resolves on one chosen target (as opposed to a shape or the caster).
117    Targeted,
118    /// Applies something that lasts (DoT / HoT / timed buff or debuff).
119    Duration,
120    /// Puts an entity on the field that fights on its own.
121    Summon,
122    /// Resolves on the caster. (`Self` is a reserved word in Rust; the tag is
123    /// spelled `SelfCast` everywhere — code, config and schema.)
124    SelfCast,
125    /// Reduces incoming damage or otherwise shields.
126    Protection,
127    /// Occupies the caster for the whole cast (no channelled ability ships yet;
128    /// the tag exists so `Any except Channelled` supports stay honest when one
129    /// does).
130    Channelled,
131}
132
133#[derive(
134    Debug,
135    Clone,
136    Copy,
137    EnumString,
138    Sequence,
139    Display,
140    Deserialize,
141    Serialize,
142    Hash,
143    Eq,
144    PartialEq,
145    EnumIter,
146    Default,
147    JsonSchema,
148    Tsify,
149)]
150#[tsify(namespace)]
151pub enum AbilityCastType {
152    #[default]
153    NoAnimation,
154    Basic,
155    Spell,
156}
157
158/// Whom an ability can target. Sourced from the ability content `target_type`
159/// and consumed by the fight target-selection (`is_valid_target` / `try_cast`).
160#[derive(
161    Debug,
162    Clone,
163    Copy,
164    EnumString,
165    Sequence,
166    Display,
167    Deserialize,
168    Serialize,
169    Hash,
170    Eq,
171    PartialEq,
172    EnumIter,
173    Default,
174    JsonSchema,
175    Tsify,
176)]
177#[tsify(namespace)]
178pub enum AbilityTargetType {
179    #[default]
180    Enemy,
181    Ally,
182    // `Self` is a reserved word in Rust; keep the wire/content value `Self`.
183    #[serde(rename = "Self")]
184    #[strum(serialize = "Self")]
185    Zelf,
186}
187
188impl AbilityTargetType {
189    /// The content/wire string ("Enemy" / "Ally" / "Self") used by the fight
190    /// targeting code.
191    pub fn as_str(&self) -> &'static str {
192        match self {
193            AbilityTargetType::Enemy => "Enemy",
194            AbilityTargetType::Ally => "Ally",
195            AbilityTargetType::Zelf => "Self",
196        }
197    }
198}
199
200/// Тип крутки гачи способностей.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Tsify)]
202#[tsify(namespace)]
203pub enum AbilityCaseRollType {
204    Small,
205    Big,
206}
207
208// NOTE: no `Eq`/`Hash` — `eff` is an `f64`. `AbilityRarity` is only ever held in
209// `Vec<AbilityRarity>`, never hashed or used as a map key, so this is safe.
210#[derive(
211    Default, PartialEq, Debug, Clone, Serialize, Deserialize, Tsify, JsonSchema, i18n::Translate,
212)]
213pub struct AbilityRarity {
214    #[schemars(schema_with = "id_schema")]
215    pub id: AbilityRarityId,
216
217    #[schemars(title = "Название редкости")]
218    #[translate]
219    pub name: i18n::I18nString,
220
221    #[schemars(title = "Сортировка")]
222    pub order: u64,
223
224    #[schemars(title = "Цвет редкости", schema_with = "color_schema")]
225    pub color: String,
226
227    #[schemars(title = "Цвет заднего фона редкости", schema_with = "color_schema")]
228    pub bg_color: String,
229
230    #[schemars(title = "Иконка рамки", schema_with = "webp_url_schema")]
231    pub icon_url: String,
232
233    #[schemars(title = "Рамка", schema_with = "asset_ability_rarity_icon_schema")]
234    pub icon_path: String,
235
236    #[schemars(
237        title = "Квадратная рамка",
238        schema_with = "asset_ability_rarity_square_icon_schema"
239    )]
240    pub square_icon_path: String,
241
242    #[schemars(
243        title = "Эффективность редкости (eff)",
244        description = "Множитель урона способностей этой редкости (ability_rarity_eff). Используется в balance::ability_eff."
245    )]
246    #[serde(default)]
247    pub eff: f64,
248}
249
250#[derive(
251    Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema, Default, i18n::Translate,
252)]
253#[tsify(from_wasm_abi)]
254pub struct AbilityTemplate {
255    #[schemars(schema_with = "id_schema")]
256    pub id: AbilityId,
257
258    #[schemars(title = "Видимость в UI боя")]
259    pub is_fight_ui_visible: bool,
260
261    #[schemars(title = "Тип видимости в UI боя")]
262    pub fight_ui_visibility: AbilityFightUiVisibility,
263
264    #[schemars(title = "Имя способности")]
265    #[translate]
266    pub name: i18n::I18nString,
267
268    #[schemars(
269        title = "Нативная функция старта каста",
270        description = "Имя нативной функции категории start_cast_ability, выполняющей старт каста способности.",
271        schema_with = "start_cast_ability_ref_schema"
272    )]
273    #[serde(default)]
274    pub start_behavior: Option<String>,
275
276    #[schemars(
277        title = "Нативная функция каста способности",
278        description = "Имя нативной функции категории cast_ability, выполняющей каст способности.",
279        schema_with = "cast_ability_ref_schema"
280    )]
281    #[serde(default)]
282    pub behavior: Option<String>,
283
284    #[schemars(
285        title = "Ссылка на иконку способности",
286        schema_with = "webp_url_schema"
287    )]
288    pub icon_url: String,
289
290    #[schemars(title = "Иконка", schema_with = "asset_ability_icon_schema")]
291    pub icon_path: String,
292
293    #[schemars(title = "Перезарядка способности")]
294    pub cooldown: u64,
295
296    /// Mana spent by one cast. The cast does not happen while the caster's pool
297    /// is short: the ability waits for regen, its cooldown keeps running.
298    /// Mob and pet-ult casts ignore this (they carry no mana pool).
299    #[schemars(
300        title = "Стоимость каста в мане",
301        description = "Сколько маны списывается за один каст. 0 — способность не тратит ману."
302    )]
303    pub mana_cost: i64,
304
305    #[schemars(title = "Показывается в окне гачи, умеет выпадать из гачи")]
306    pub is_gacha_ability: bool,
307
308    #[schemars(title = "Доступна ботам (для генерации оппонентов)")]
309    #[serde(default)]
310    pub available_to_bots: bool,
311
312    #[schemars(title = "Id редкости", schema_with = "ability_rarity_link_id_schema")]
313    pub rarity_id: AbilityRarityId,
314
315    #[schemars(title = "Описание способности")]
316    #[translate]
317    pub description: i18n::I18nString,
318
319    #[schemars(
320        title = "Скрипт, возвращающий значения для подстановки в описание",
321        schema_with = "option_script_schema"
322    )]
323    pub description_values_script: Option<String>,
324
325    #[schemars(title = "VFX", schema_with = "asset_vfx_object_schema")]
326    pub vfx_object_path: String,
327
328    #[schemars(title = "Тип каста")]
329    pub cast_type: AbilityCastType,
330
331    #[schemars(title = "Тип цели (Enemy / Ally / Self)")]
332    #[serde(default)]
333    pub target_type: AbilityTargetType,
334
335    #[schemars(title = "Дальность каста (в клетках)")]
336    #[serde(default)]
337    pub range: i64,
338
339    /// AoE cap: max enemies a band/cone AoE ability hits per cast, taking the
340    /// first N band-eligible targets in deterministic `fight.entities` order.
341    /// `None` = unlimited (legacy behaviour, back-compat). Consumed by the AoE
342    /// cast behaviors (`cone_strike`, Rewind, War Cry, Holy Nova).
343    #[schemars(title = "AoE: макс. целей за каст (пусто = без лимита)")]
344    pub max_targets: Option<i64>,
345
346    /// What this ability does, for systems that react to a KIND of ability
347    /// rather than to a specific one. Required and explicit — an untagged
348    /// ability is written `tags: []`, never omitted.
349    #[schemars(
350        title = "Теги способности",
351        description = "На них кейтятся законы, камни поддержки (Compatibility) и триггеры. Пустой список — способность без тегов."
352    )]
353    pub tags: Vec<AbilityTag>,
354}
355
356impl AbilityTemplate {
357    pub fn has_tag(&self, tag: AbilityTag) -> bool {
358        self.tags.contains(&tag)
359    }
360
361    pub fn has_any_tag(&self, tags: &[AbilityTag]) -> bool {
362        tags.iter().any(|tag| self.has_tag(*tag))
363    }
364}
365
366#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
367#[tsify(from_wasm_abi)]
368pub struct Ability {
369    pub template_id: AbilityId,
370    pub level: i64,
371    pub shards_amount: i64,
372}
373
374impl Ability {
375    pub fn from_template(
376        ability_template: &AbilityTemplate,
377        level: Option<i64>,
378        shards_amount: Option<i64>,
379    ) -> Self {
380        Ability {
381            template_id: ability_template.id,
382            level: level.unwrap_or(1),
383            shards_amount: shards_amount.unwrap_or(0),
384        }
385    }
386}
387
388#[derive(Clone, Debug, Serialize, Deserialize, Eq, JsonSchema, Tsify)]
389pub struct ActiveAbility {
390    pub ability: Ability,
391    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
392    pub deadline: Option<chrono::DateTime<chrono::Utc>>,
393    pub slot_id: Option<AbilitySlotId>,
394}
395
396impl PartialEq for ActiveAbility {
397    fn eq(&self, other: &Self) -> bool {
398        self.ability == other.ability
399            && self.slot_id == other.slot_id
400            && self.deadline.zip(other.deadline).map_or(
401                self.deadline.is_none() && other.deadline.is_none(),
402                |(a, b)| a.signed_duration_since(b).abs() <= chrono::TimeDelta::milliseconds(250),
403            )
404    }
405}
406
407#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
408pub struct UpgradedAbilitiesMap(pub HashMap<AbilityId, (i64, i64)>);
409
410impl UpgradedAbilitiesMap {
411    pub fn upgrade(&mut self, id: AbilityId, level: i64) {
412        self.0
413            .entry(id)
414            .and_modify(|(_, max)| {
415                *max += 1;
416            })
417            .or_insert((level, level + 1));
418    }
419
420    pub fn insert(&mut self, id: AbilityId, levels: (i64, i64)) {
421        self.0.insert(id, levels);
422    }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
426pub struct AbilityShard {
427    pub ability_id: AbilityId,
428    pub shards_amount: i64,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
432pub struct AbilityDrop {
433    pub template: AbilityTemplate,
434    pub is_new: bool,
435    pub evolved_from: Option<AbilityTemplate>,
436    pub is_checkpoint: bool,
437}
438
439#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify, Default)]
440#[tsify(from_wasm_abi)]
441pub struct EquippedAbilities {
442    pub slotted: BTreeMap<AbilitySlotId, Ability>,
443    pub unslotted: Vec<Ability>,
444}
445
446impl EquippedAbilities {
447    pub fn new() -> Self {
448        Self {
449            slotted: BTreeMap::new(),
450            unslotted: Vec::new(),
451        }
452    }
453
454    pub fn add(&mut self, ability: Ability, slot_id: Option<AbilitySlotId>) {
455        if let Some(slot_id) = slot_id {
456            self.slotted.insert(slot_id, ability);
457        } else {
458            self.unslotted.push(ability);
459        }
460    }
461
462    pub fn to_vec(&self) -> Vec<&Ability> {
463        self.unslotted.iter().chain(self.slotted.values()).collect()
464    }
465
466    pub fn to_vec_mut(&mut self) -> Vec<&mut Ability> {
467        self.unslotted
468            .iter_mut()
469            .chain(self.slotted.values_mut())
470            .collect()
471    }
472
473    pub fn get_by_slot_id(&self, slot_id: AbilitySlotId) -> Option<&Ability> {
474        self.slotted.get(&slot_id)
475    }
476
477    pub fn has_ability(&self, ability_id: AbilityId) -> bool {
478        self.slotted.values().any(|a| a.template_id == ability_id)
479            || self.unslotted.iter().any(|a| a.template_id == ability_id)
480    }
481
482    pub fn get_mut_by_id(&mut self, ability_id: AbilityId) -> Option<&mut Ability> {
483        self.slotted
484            .values_mut()
485            .find(|a| a.template_id == ability_id)
486            .or_else(|| {
487                self.unslotted
488                    .iter_mut()
489                    .find(|a| a.template_id == ability_id)
490            })
491    }
492
493    pub fn remove_by_slot_id(&mut self, slot_id: AbilitySlotId) -> Option<Ability> {
494        self.slotted.remove(&slot_id)
495    }
496
497    pub fn len(&self) -> usize {
498        self.slotted.len() + self.unslotted.len()
499    }
500
501    pub fn is_empty(&self) -> bool {
502        self.slotted.is_empty() && self.unslotted.is_empty()
503    }
504
505    pub fn clear(&mut self) {
506        self.slotted.clear();
507        self.unslotted.clear();
508    }
509}