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(Default, PartialEq, Debug, Clone, Serialize, Deserialize, Tsify, JsonSchema)]
211pub struct AbilityRarity {
212    #[schemars(schema_with = "id_schema")]
213    pub id: AbilityRarityId,
214
215    #[schemars(title = "Название редкости")]
216    pub name: i18n::I18nString,
217
218    #[schemars(title = "Сортировка")]
219    pub order: u64,
220
221    #[schemars(title = "Цвет редкости", schema_with = "color_schema")]
222    pub color: String,
223
224    #[schemars(title = "Цвет заднего фона редкости", schema_with = "color_schema")]
225    pub bg_color: String,
226
227    #[schemars(title = "Иконка рамки", schema_with = "webp_url_schema")]
228    pub icon_url: String,
229
230    #[schemars(title = "Рамка", schema_with = "asset_ability_rarity_icon_schema")]
231    pub icon_path: String,
232
233    #[schemars(
234        title = "Квадратная рамка",
235        schema_with = "asset_ability_rarity_square_icon_schema"
236    )]
237    pub square_icon_path: String,
238
239    #[schemars(
240        title = "Эффективность редкости (eff)",
241        description = "Множитель урона способностей этой редкости (ability_rarity_eff). Используется в balance::ability_eff."
242    )]
243    #[serde(default)]
244    pub eff: f64,
245}
246
247#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema, Default)]
248#[tsify(from_wasm_abi)]
249pub struct AbilityTemplate {
250    #[schemars(schema_with = "id_schema")]
251    pub id: AbilityId,
252
253    #[schemars(title = "Видимость в UI боя")]
254    pub is_fight_ui_visible: bool,
255
256    #[schemars(title = "Тип видимости в UI боя")]
257    pub fight_ui_visibility: AbilityFightUiVisibility,
258
259    #[schemars(title = "Имя способности")]
260    pub name: i18n::I18nString,
261
262    #[schemars(
263        title = "Нативная функция старта каста",
264        description = "Имя нативной функции категории start_cast_ability, выполняющей старт каста способности.",
265        schema_with = "start_cast_ability_ref_schema"
266    )]
267    #[serde(default)]
268    pub start_behavior: Option<String>,
269
270    #[schemars(
271        title = "Нативная функция каста способности",
272        description = "Имя нативной функции категории cast_ability, выполняющей каст способности.",
273        schema_with = "cast_ability_ref_schema"
274    )]
275    #[serde(default)]
276    pub behavior: Option<String>,
277
278    #[schemars(
279        title = "Ссылка на иконку способности",
280        schema_with = "webp_url_schema"
281    )]
282    pub icon_url: String,
283
284    #[schemars(title = "Иконка", schema_with = "asset_ability_icon_schema")]
285    pub icon_path: String,
286
287    #[schemars(title = "Перезарядка способности")]
288    pub cooldown: u64,
289
290    /// Mana spent by one cast. The cast does not happen while the caster's pool
291    /// is short: the ability waits for regen, its cooldown keeps running.
292    /// Mob and pet-ult casts ignore this (they carry no mana pool).
293    #[schemars(
294        title = "Стоимость каста в мане",
295        description = "Сколько маны списывается за один каст. 0 — способность не тратит ману."
296    )]
297    pub mana_cost: i64,
298
299    #[schemars(title = "Показывается в окне гачи, умеет выпадать из гачи")]
300    pub is_gacha_ability: bool,
301
302    #[schemars(title = "Доступна ботам (для генерации оппонентов)")]
303    #[serde(default)]
304    pub available_to_bots: bool,
305
306    #[schemars(title = "Id редкости", schema_with = "ability_rarity_link_id_schema")]
307    pub rarity_id: AbilityRarityId,
308
309    #[schemars(title = "Описание способности")]
310    pub description: i18n::I18nString,
311
312    #[schemars(
313        title = "Скрипт, возвращающий значения для подстановки в описание",
314        schema_with = "option_script_schema"
315    )]
316    pub description_values_script: Option<String>,
317
318    #[schemars(title = "VFX", schema_with = "asset_vfx_object_schema")]
319    pub vfx_object_path: String,
320
321    #[schemars(title = "Тип каста")]
322    pub cast_type: AbilityCastType,
323
324    #[schemars(title = "Тип цели (Enemy / Ally / Self)")]
325    #[serde(default)]
326    pub target_type: AbilityTargetType,
327
328    #[schemars(title = "Дальность каста (в клетках)")]
329    #[serde(default)]
330    pub range: i64,
331
332    /// AoE cap: max enemies a band/cone AoE ability hits per cast, taking the
333    /// first N band-eligible targets in deterministic `fight.entities` order.
334    /// `None` = unlimited (legacy behaviour, back-compat). Consumed by the AoE
335    /// cast behaviors (`cone_strike`, Rewind, War Cry, Holy Nova).
336    #[schemars(title = "AoE: макс. целей за каст (пусто = без лимита)")]
337    pub max_targets: Option<i64>,
338
339    /// What this ability does, for systems that react to a KIND of ability
340    /// rather than to a specific one. Required and explicit — an untagged
341    /// ability is written `tags: []`, never omitted.
342    #[schemars(
343        title = "Теги способности",
344        description = "На них кейтятся законы, камни поддержки (Compatibility) и триггеры. Пустой список — способность без тегов."
345    )]
346    pub tags: Vec<AbilityTag>,
347}
348
349impl AbilityTemplate {
350    pub fn has_tag(&self, tag: AbilityTag) -> bool {
351        self.tags.contains(&tag)
352    }
353
354    pub fn has_any_tag(&self, tags: &[AbilityTag]) -> bool {
355        tags.iter().any(|tag| self.has_tag(*tag))
356    }
357}
358
359#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
360#[tsify(from_wasm_abi)]
361pub struct Ability {
362    pub template_id: AbilityId,
363    pub level: i64,
364    pub shards_amount: i64,
365}
366
367impl Ability {
368    pub fn from_template(
369        ability_template: &AbilityTemplate,
370        level: Option<i64>,
371        shards_amount: Option<i64>,
372    ) -> Self {
373        Ability {
374            template_id: ability_template.id,
375            level: level.unwrap_or(1),
376            shards_amount: shards_amount.unwrap_or(0),
377        }
378    }
379}
380
381#[derive(Clone, Debug, Serialize, Deserialize, Eq, JsonSchema, Tsify)]
382pub struct ActiveAbility {
383    pub ability: Ability,
384    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
385    pub deadline: Option<chrono::DateTime<chrono::Utc>>,
386    pub slot_id: Option<AbilitySlotId>,
387}
388
389impl PartialEq for ActiveAbility {
390    fn eq(&self, other: &Self) -> bool {
391        self.ability == other.ability
392            && self.slot_id == other.slot_id
393            && self.deadline.zip(other.deadline).map_or(
394                self.deadline.is_none() && other.deadline.is_none(),
395                |(a, b)| a.signed_duration_since(b).abs() <= chrono::TimeDelta::milliseconds(250),
396            )
397    }
398}
399
400#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
401pub struct UpgradedAbilitiesMap(pub HashMap<AbilityId, (i64, i64)>);
402
403impl UpgradedAbilitiesMap {
404    pub fn upgrade(&mut self, id: AbilityId, level: i64) {
405        self.0
406            .entry(id)
407            .and_modify(|(_, max)| {
408                *max += 1;
409            })
410            .or_insert((level, level + 1));
411    }
412
413    pub fn insert(&mut self, id: AbilityId, levels: (i64, i64)) {
414        self.0.insert(id, levels);
415    }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
419pub struct AbilityShard {
420    pub ability_id: AbilityId,
421    pub shards_amount: i64,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
425pub struct AbilityDrop {
426    pub template: AbilityTemplate,
427    pub is_new: bool,
428    pub evolved_from: Option<AbilityTemplate>,
429    pub is_checkpoint: bool,
430}
431
432#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify, Default)]
433#[tsify(from_wasm_abi)]
434pub struct EquippedAbilities {
435    pub slotted: BTreeMap<AbilitySlotId, Ability>,
436    pub unslotted: Vec<Ability>,
437}
438
439impl EquippedAbilities {
440    pub fn new() -> Self {
441        Self {
442            slotted: BTreeMap::new(),
443            unslotted: Vec::new(),
444        }
445    }
446
447    pub fn add(&mut self, ability: Ability, slot_id: Option<AbilitySlotId>) {
448        if let Some(slot_id) = slot_id {
449            self.slotted.insert(slot_id, ability);
450        } else {
451            self.unslotted.push(ability);
452        }
453    }
454
455    pub fn to_vec(&self) -> Vec<&Ability> {
456        self.unslotted.iter().chain(self.slotted.values()).collect()
457    }
458
459    pub fn to_vec_mut(&mut self) -> Vec<&mut Ability> {
460        self.unslotted
461            .iter_mut()
462            .chain(self.slotted.values_mut())
463            .collect()
464    }
465
466    pub fn get_by_slot_id(&self, slot_id: AbilitySlotId) -> Option<&Ability> {
467        self.slotted.get(&slot_id)
468    }
469
470    pub fn has_ability(&self, ability_id: AbilityId) -> bool {
471        self.slotted.values().any(|a| a.template_id == ability_id)
472            || self.unslotted.iter().any(|a| a.template_id == ability_id)
473    }
474
475    pub fn get_mut_by_id(&mut self, ability_id: AbilityId) -> Option<&mut Ability> {
476        self.slotted
477            .values_mut()
478            .find(|a| a.template_id == ability_id)
479            .or_else(|| {
480                self.unslotted
481                    .iter_mut()
482                    .find(|a| a.template_id == ability_id)
483            })
484    }
485
486    pub fn remove_by_slot_id(&mut self, slot_id: AbilitySlotId) -> Option<Ability> {
487        self.slotted.remove(&slot_id)
488    }
489
490    pub fn len(&self) -> usize {
491        self.slotted.len() + self.unslotted.len()
492    }
493
494    pub fn is_empty(&self) -> bool {
495        self.slotted.is_empty() && self.unslotted.is_empty()
496    }
497
498    pub fn clear(&mut self) {
499        self.slotted.clear();
500        self.unslotted.clear();
501    }
502}