essences/
ability_stones.rs

1use crate::prelude::*;
2
3use std::collections::{BTreeMap, HashMap};
4use strum_macros::{Display, EnumString};
5
6use crate::abilities::{AbilityId, AbilityTag};
7
8#[declare]
9pub type AbilityStoneId = Uuid;
10
11/// Index of one ability socket, `0..ability_stone_settings.sockets.len()`.
12/// The socket — not the stone — carries the [`crate::flip::WorldSide`], so the
13/// same stone fits any of the four sockets.
14#[declare]
15pub type AbilityStoneSocketIndex = i64;
16
17/// One operation a support stone performs on the ability it is socketed into.
18///
19/// A support carries a SET of these (`Haste` = payload ×0.80 AND mana ×1.10 AND
20/// cooldown ×0.75), so the operation kind is per-op, not per-stone. Rank scales
21/// the magnitude of each op; it never changes the op set, the Compatibility or
22/// the Exclusion Family.
23#[derive(
24    Clone,
25    Copy,
26    Debug,
27    Default,
28    Serialize,
29    Deserialize,
30    PartialEq,
31    Eq,
32    Hash,
33    JsonSchema,
34    Tsify,
35    Display,
36    EnumString,
37)]
38#[tsify(namespace)]
39pub enum AbilityStoneOpKind {
40    /// Payload multiplier: every damage/DoT/heal component of the ability is
41    /// multiplied by `value` (1.50 = +50%). Also the "magnitude per target /
42    /// per tick" knob of Widen / Persist / Sustain.
43    #[default]
44    PayloadMult,
45    /// Mana-cost multiplier (0.50 = half price).
46    ManaCostMult,
47    /// Cooldown multiplier (0.75 = casts 33% more often).
48    CooldownMult,
49    /// Cast-time multiplier: scales the cast's delay + animation occupation
50    /// (0.50 = Quickcast).
51    CastTimeMult,
52    /// Applied-effect duration multiplier (2.0 = Persist).
53    EffectDurationMult,
54    /// Flat added crit chance in PERCENT POINTS (25.0 = +25% crit chance).
55    CritChanceBonus,
56    /// Coverage multiplier: the ability's AoE target cap is multiplied by
57    /// `value` (1.60 = Widen). Inert on abilities with no cap (already
58    /// unlimited).
59    CoverageMult,
60    /// `+value` flat targets for AoE abilities. Legacy "Охват" op, kept because
61    /// it is the only additive coverage form.
62    ExtraTargets,
63    /// Collapse copies/area into one primary payload multiplied by `value`;
64    /// coverage becomes a single target (Condense).
65    Condense,
66    /// `count` extra instant copies on the SAME target, each at `value`% of the
67    /// payload (Split).
68    SplitCopies,
69    /// The payload jumps to `count` further targets at `value`% each (Chain).
70    ChainTargets,
71    /// The payload passes through `count` further targets; each next hit is
72    /// `value`% of the previous one (Pierce).
73    PierceTargets,
74    /// `count` derived repeats of the cast `interval_ms` later, each at
75    /// `value`% payload (Repeat).
76    RepeatCast,
77    /// The single payload becomes `count` pulses at `value`% each, spaced
78    /// `interval_ms` apart (Pulse). The first pulse is the original cast.
79    PulseSplit,
80    /// `value`% of the final damage dealt heals the caster (Leech).
81    LeechPercent,
82    /// The next incoming hit after the cast deals `value`% less damage
83    /// (Fortify).
84    GuardNextHit,
85    /// Incoming damage is reduced by `value`% for `interval_ms` after the cast
86    /// (Shelter).
87    IncomingDamageReduction,
88    /// `+value%` damage for every 10% of the caster's missing mana. Legacy
89    /// "Отдача" op.
90    MissingManaDamagePercent,
91}
92
93/// Twelve exclusion families. Two supports of the SAME family may not sit in
94/// one Imprint (the two sockets of one side of one ability).
95#[derive(
96    Clone,
97    Copy,
98    Debug,
99    Default,
100    Serialize,
101    Deserialize,
102    PartialEq,
103    Eq,
104    Hash,
105    JsonSchema,
106    Tsify,
107    Display,
108    EnumString,
109)]
110#[tsify(namespace)]
111pub enum AbilityStoneFamily {
112    #[default]
113    Economy,
114    Cadence,
115    Delivery,
116    Magnitude,
117    Critical,
118    Shape,
119    Coverage,
120    Targeting,
121    Replay,
122    Duration,
123    Conversion,
124    Defense,
125}
126
127/// Which abilities a support accepts, as a predicate over [`AbilityTag`]s.
128///
129/// `required_any_of` empty = `Any`. `forbidden` expresses the doc's
130/// "Any except Channelled" form and composes with the required set.
131#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
132pub struct AbilityStoneCompatibility {
133    #[schemars(
134        title = "Требуемые теги (любой из)",
135        description = "Пустой список — подходит любой способности (Any)."
136    )]
137    pub required_any_of: Vec<AbilityTag>,
138
139    #[schemars(
140        title = "Запрещённые теги",
141        description = "Способность с любым из этих тегов камень не принимает (форма «Any except ...»)."
142    )]
143    pub forbidden: Vec<AbilityTag>,
144}
145
146impl AbilityStoneCompatibility {
147    /// Any ability at all.
148    pub fn any() -> Self {
149        Self::default()
150    }
151
152    /// Whether an ability carrying `tags` accepts this support.
153    pub fn accepts(&self, tags: &[AbilityTag]) -> bool {
154        if self.forbidden.iter().any(|tag| tags.contains(tag)) {
155            return false;
156        }
157        self.required_any_of.is_empty() || self.required_any_of.iter().any(|tag| tags.contains(tag))
158    }
159}
160
161/// One operation of a support stone, with its rank scaling.
162#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Tsify)]
163pub struct AbilityStoneOp {
164    #[schemars(title = "Тип операции")]
165    pub kind: AbilityStoneOpKind,
166
167    #[schemars(
168        title = "Величина на 1 ранге",
169        description = "Множитель для *Mult-операций, проценты для процентных, количество для ExtraTargets."
170    )]
171    pub base_value: f64,
172
173    #[schemars(
174        title = "Прирост величины за ранг",
175        description = "Величина на ранге L = base_value + value_per_level * (L - 1). Ранг меняет только величину, не операцию."
176    )]
177    pub value_per_level: f64,
178
179    #[schemars(
180        title = "Структурное число (копии / цели / пульсы)",
181        description = "Количество производных копий, доп. целей или пульсов. От ранга не зависит. 0 — операция не структурная."
182    )]
183    pub count: i64,
184
185    #[schemars(
186        title = "Интервал / длительность операции, мс",
187        description = "Задержка производного повтора, шаг между пульсами, длительность защитного окна. От ранга не зависит."
188    )]
189    pub interval_ms: i64,
190}
191
192impl AbilityStoneOp {
193    /// A plain scalar op (no structural count, no timing).
194    pub fn scalar(kind: AbilityStoneOpKind, base_value: f64, value_per_level: f64) -> Self {
195        Self {
196            kind,
197            base_value,
198            value_per_level,
199            count: 0,
200            interval_ms: 0,
201        }
202    }
203
204    /// Magnitude at `level` (levels are 1-based).
205    pub fn value_at_level(&self, level: i64) -> f64 {
206        let steps = (level - 1).max(0) as f64;
207        self.base_value + self.value_per_level * steps
208    }
209}
210
211/// One ability stone in the catalog. Ability stones are their OWN collection —
212/// they never mix with item stones or artifact stones and cannot be socketed
213/// into their sockets.
214#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Tsify)]
215pub struct AbilityStoneTemplate {
216    #[schemars(schema_with = "id_schema")]
217    pub id: AbilityStoneId,
218
219    #[schemars(title = "Название камня способности")]
220    pub name: i18n::I18nString,
221
222    #[schemars(title = "Описание камня способности")]
223    pub description: i18n::I18nString,
224
225    #[schemars(
226        title = "Семейство исключения",
227        description = "Два камня одного семейства нельзя поставить в один Imprint (два сокета одной стороны одной способности)."
228    )]
229    pub family: AbilityStoneFamily,
230
231    #[schemars(
232        title = "Совместимость",
233        description = "Каким способностям камень подходит, в терминах тегов способности."
234    )]
235    pub compatibility: AbilityStoneCompatibility,
236
237    #[schemars(
238        title = "Операции камня",
239        description = "Один камень несёт набор операций сразу (например payload ×0.80 + mana ×1.10 + cooldown ×0.75)."
240    )]
241    pub ops: Vec<AbilityStoneOp>,
242
243    #[schemars(
244        title = "Множитель Power на первом ранге",
245        description = "Приблизительный вклад в displayed/matchmaking Power (BAL-030). 1.0 = не влияет. Промежуточные ранги интерполируются линейно по ln(q)."
246    )]
247    pub power_q_first_rank: f64,
248
249    #[schemars(
250        title = "Множитель Power на максимальном ранге",
251        description = "Значение того же множителя на последнем ранге. Должен быть не меньше значения на первом."
252    )]
253    pub power_q_max_rank: f64,
254
255    #[schemars(
256        title = "Иконка",
257        schema_with = "schema_loader::asset_ability_stone_icon_schema"
258    )]
259    pub icon_path: String,
260}
261
262/// One owned ability-stone stack. Upgrades are paid in RAW COPIES of the same
263/// stone (the copies' own level is irrelevant), so ownership is a per-template
264/// stack, not a bag of instances.
265#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
266#[tsify(from_wasm_abi)]
267pub struct OwnedAbilityStone {
268    pub template_id: AbilityStoneId,
269    pub level: i64,
270    /// Raw copies held toward the next level.
271    pub copies: i64,
272}
273
274impl OwnedAbilityStone {
275    pub fn new(template_id: AbilityStoneId) -> Self {
276        Self {
277            template_id,
278            level: 1,
279            copies: 0,
280        }
281    }
282}
283
284/// What one `UpgradeAllAbilityStones` call actually changed: template id ->
285/// (from, to) level. Modelled on `UpgradedStonesMap` and used the same way —
286/// the client shows its result window from this.
287///
288/// Only stones that actually gained a level appear; one whose bank was too
289/// short is simply absent rather than present with an unchanged pair.
290#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
291pub struct UpgradedAbilityStonesMap(pub HashMap<AbilityStoneId, (i64, i64)>);
292
293impl UpgradedAbilityStonesMap {
294    pub fn insert(&mut self, id: AbilityStoneId, levels: (i64, i64)) {
295        self.0.insert(id, levels);
296    }
297
298    pub fn is_empty(&self) -> bool {
299        self.0.is_empty()
300    }
301}
302
303/// One granted stone copy, for the client drop popup and quest progression.
304#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
305pub struct AbilityStoneDrop {
306    pub stone_id: AbilityStoneId,
307    /// Copies granted by this drop.
308    pub copies: i64,
309    /// The player owned no copy of this stone before the drop.
310    pub is_new: bool,
311}
312
313/// Socketed ability stones: ability -> socket index -> stone template.
314#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
315#[tsify(from_wasm_abi)]
316pub struct AbilityStoneSockets(
317    pub BTreeMap<AbilityId, BTreeMap<AbilityStoneSocketIndex, AbilityStoneId>>,
318);
319
320impl AbilityStoneSockets {
321    pub fn get(
322        &self,
323        ability_id: AbilityId,
324        socket_index: AbilityStoneSocketIndex,
325    ) -> Option<AbilityStoneId> {
326        self.0
327            .get(&ability_id)
328            .and_then(|sockets| sockets.get(&socket_index))
329            .copied()
330    }
331
332    pub fn set(
333        &mut self,
334        ability_id: AbilityId,
335        socket_index: AbilityStoneSocketIndex,
336        stone_id: AbilityStoneId,
337    ) {
338        self.0
339            .entry(ability_id)
340            .or_default()
341            .insert(socket_index, stone_id);
342    }
343
344    pub fn clear_socket(
345        &mut self,
346        ability_id: AbilityId,
347        socket_index: AbilityStoneSocketIndex,
348    ) -> Option<AbilityStoneId> {
349        let sockets = self.0.get_mut(&ability_id)?;
350        let removed = sockets.remove(&socket_index);
351        if sockets.is_empty() {
352            self.0.remove(&ability_id);
353        }
354        removed
355    }
356
357    /// Every stone socketed into `ability_id`, in socket order.
358    pub fn sockets_of(
359        &self,
360        ability_id: AbilityId,
361    ) -> impl Iterator<Item = (AbilityStoneSocketIndex, AbilityStoneId)> + '_ {
362        self.0
363            .get(&ability_id)
364            .into_iter()
365            .flat_map(|sockets| sockets.iter().map(|(index, stone)| (*index, *stone)))
366    }
367
368    /// Whether `stone_id` already occupies a socket of `ability_id`. A stone
369    /// template may hold at most one socket per ability (stub rule: it stops a
370    /// single copy from filling all four sockets of one ability).
371    pub fn is_socketed_in(&self, ability_id: AbilityId, stone_id: AbilityStoneId) -> bool {
372        self.sockets_of(ability_id).any(|(_, id)| id == stone_id)
373    }
374}
375
376/// One derived-copy shape produced by a support: `count` extra hits at
377/// `payload` of the ability's payload. Derived hits are NOT Core events — they
378/// never re-run `on_cast`, never charge the pet bar and are tagged `derived` in
379/// their damage payload so no future law/trigger/mastery reader counts them.
380#[derive(Clone, Copy, Debug, Default, PartialEq)]
381pub struct DerivedCopies {
382    /// How many extra hits.
383    pub count: i64,
384    /// Payload share of each extra hit (0.70 = 70% of the ability's payload).
385    /// For [`AbilityStoneOpKind::PierceTargets`] this is the falloff applied
386    /// cumulatively (hit 1 = 0.70, hit 2 = 0.49, ...).
387    pub payload: f64,
388    /// Spacing/delay in ms; 0 = same tick as the original hit.
389    pub interval_ms: u64,
390}
391
392impl DerivedCopies {
393    pub fn is_active(&self) -> bool {
394        self.count > 0 && self.payload > 0.0
395    }
396}
397
398/// Aggregated numeric modifiers of the stones ACTIVE on one ability — the two
399/// sockets whose side matches the currently active [`crate::flip::WorldSide`].
400#[derive(Clone, Copy, Debug, PartialEq)]
401pub struct AbilityStoneMods {
402    /// Multiplier on every damage component.
403    pub damage_mult: f64,
404    /// Multiplier on the mana cost.
405    pub mana_cost_mult: f64,
406    /// Multiplier on the cooldown.
407    pub cooldown_mult: f64,
408    /// Multiplier on the cast's delay + animation occupation.
409    pub cast_time_mult: f64,
410    /// Multiplier on applied-effect duration.
411    pub effect_duration_mult: f64,
412    /// Multiplier on the AoE target cap.
413    pub coverage_mult: f64,
414    /// Extra AoE targets (additive, applied after `coverage_mult`).
415    pub extra_targets: i64,
416    /// Coverage collapses to a single target and copies fold into it.
417    pub condense: bool,
418    /// Added crit chance, as a fraction (0.25 = +25 percent points).
419    pub crit_chance_bonus: f64,
420    /// Extra damage fraction per 10% of missing mana.
421    pub missing_mana_damage_per_10pct: f64,
422    /// Extra instant copies on the same target (Split).
423    pub split: DerivedCopies,
424    /// Extra jumps to further targets (Chain).
425    pub chain: DerivedCopies,
426    /// Extra pierced targets with cumulative falloff (Pierce).
427    pub pierce: DerivedCopies,
428    /// Delayed derived repeats of the whole cast (Repeat).
429    pub repeat: DerivedCopies,
430    /// Extra delayed pulses of the cast (Pulse). The original cast is pulse #1,
431    /// so `count` here is the number of EXTRA pulses.
432    pub pulse: DerivedCopies,
433    /// Share of the final damage that heals the caster (Leech).
434    pub leech_fraction: f64,
435    /// Damage reduction of the next incoming hit (Fortify), as a fraction.
436    pub guard_next_hit: f64,
437    /// Incoming-damage reduction after the cast (Shelter), as a fraction.
438    pub incoming_damage_reduction: f64,
439    /// How long the Shelter window lasts, in ms.
440    pub incoming_damage_reduction_ms: u64,
441}
442
443impl Default for AbilityStoneMods {
444    fn default() -> Self {
445        Self::identity()
446    }
447}
448
449impl AbilityStoneMods {
450    /// The no-stones case: every ability behaves exactly as before.
451    pub const fn identity() -> Self {
452        Self {
453            damage_mult: 1.0,
454            mana_cost_mult: 1.0,
455            cooldown_mult: 1.0,
456            cast_time_mult: 1.0,
457            effect_duration_mult: 1.0,
458            coverage_mult: 1.0,
459            extra_targets: 0,
460            condense: false,
461            crit_chance_bonus: 0.0,
462            missing_mana_damage_per_10pct: 0.0,
463            split: DerivedCopies {
464                count: 0,
465                payload: 0.0,
466                interval_ms: 0,
467            },
468            chain: DerivedCopies {
469                count: 0,
470                payload: 0.0,
471                interval_ms: 0,
472            },
473            pierce: DerivedCopies {
474                count: 0,
475                payload: 0.0,
476                interval_ms: 0,
477            },
478            repeat: DerivedCopies {
479                count: 0,
480                payload: 0.0,
481                interval_ms: 0,
482            },
483            pulse: DerivedCopies {
484                count: 0,
485                payload: 0.0,
486                interval_ms: 0,
487            },
488            leech_fraction: 0.0,
489            guard_next_hit: 0.0,
490            incoming_damage_reduction: 0.0,
491            incoming_damage_reduction_ms: 0,
492        }
493    }
494
495    pub fn is_identity(&self) -> bool {
496        *self == Self::identity()
497    }
498
499    /// Whether any op produces extra hits that resolve at the attack seam
500    /// (Split / Chain / Pierce).
501    pub fn has_instant_copies(&self) -> bool {
502        self.split.is_active() || self.chain.is_active() || self.pierce.is_active()
503    }
504
505    /// Whether any op produces hits that resolve on a later tick
506    /// (Repeat / Pulse).
507    pub fn has_delayed_copies(&self) -> bool {
508        self.repeat.is_active() || self.pulse.is_active()
509    }
510
511    /// Folds one op in. Percent reductions are clamped so a stack of stones can
512    /// never invert a cost or a cooldown.
513    pub fn apply(&mut self, op: &AbilityStoneOp, value: f64) {
514        if !value.is_finite() {
515            return;
516        }
517        let copies = |payload: f64| DerivedCopies {
518            count: op.count.max(0),
519            payload: (payload / 100.0).max(0.0),
520            interval_ms: op.interval_ms.max(0) as u64,
521        };
522        match op.kind {
523            AbilityStoneOpKind::PayloadMult => {
524                self.damage_mult = (self.damage_mult * value).max(0.0);
525            }
526            AbilityStoneOpKind::ManaCostMult => {
527                self.mana_cost_mult = (self.mana_cost_mult * value).max(0.0);
528            }
529            AbilityStoneOpKind::CooldownMult => {
530                self.cooldown_mult = (self.cooldown_mult * value).max(0.0);
531            }
532            AbilityStoneOpKind::CastTimeMult => {
533                self.cast_time_mult = (self.cast_time_mult * value).max(0.0);
534            }
535            AbilityStoneOpKind::EffectDurationMult => {
536                self.effect_duration_mult = (self.effect_duration_mult * value).max(0.0);
537            }
538            AbilityStoneOpKind::CritChanceBonus => {
539                self.crit_chance_bonus += value / 100.0;
540            }
541            AbilityStoneOpKind::CoverageMult => {
542                self.coverage_mult = (self.coverage_mult * value).max(0.0);
543            }
544            AbilityStoneOpKind::ExtraTargets => {
545                self.extra_targets += value.round() as i64;
546            }
547            AbilityStoneOpKind::Condense => {
548                self.condense = true;
549                self.damage_mult = (self.damage_mult * value).max(0.0);
550            }
551            AbilityStoneOpKind::SplitCopies => self.split = copies(value),
552            AbilityStoneOpKind::ChainTargets => self.chain = copies(value),
553            AbilityStoneOpKind::PierceTargets => self.pierce = copies(value),
554            AbilityStoneOpKind::RepeatCast => self.repeat = copies(value),
555            AbilityStoneOpKind::PulseSplit => {
556                // The original cast is pulse #1: it carries the per-pulse
557                // payload, and `count - 1` derived pulses follow it.
558                let payload = (value / 100.0).max(0.0);
559                self.damage_mult = (self.damage_mult * payload).max(0.0);
560                self.pulse = DerivedCopies {
561                    count: (op.count - 1).max(0),
562                    payload: 1.0,
563                    interval_ms: op.interval_ms.max(0) as u64,
564                };
565            }
566            AbilityStoneOpKind::LeechPercent => {
567                self.leech_fraction += (value / 100.0).max(0.0);
568            }
569            AbilityStoneOpKind::GuardNextHit => {
570                self.guard_next_hit = (self.guard_next_hit + value / 100.0).clamp(0.0, 0.95);
571            }
572            AbilityStoneOpKind::IncomingDamageReduction => {
573                self.incoming_damage_reduction =
574                    (self.incoming_damage_reduction + value / 100.0).clamp(0.0, 0.95);
575                self.incoming_damage_reduction_ms = op.interval_ms.max(0) as u64;
576            }
577            AbilityStoneOpKind::MissingManaDamagePercent => {
578                self.missing_mana_damage_per_10pct += value / 100.0;
579            }
580        }
581    }
582
583    /// Damage multiplier including the missing-mana term ("Отдача"), which is
584    /// resolved at cast time against the caster's pool.
585    pub fn damage_mult_with_mana(&self, missing_mana_fraction: f64) -> f64 {
586        let missing_tenths = (missing_mana_fraction.clamp(0.0, 1.0) * 10.0).floor();
587        (self.damage_mult + self.missing_mana_damage_per_10pct * missing_tenths).max(0.0)
588    }
589
590    /// Cooldown in ticks after the cooldown stones, never shorter than one tick
591    /// for an ability that has a cooldown at all.
592    pub fn apply_cooldown(&self, cooldown_ticks: u64) -> u64 {
593        if cooldown_ticks == 0 {
594            return 0;
595        }
596        let scaled = (cooldown_ticks as f64 * self.cooldown_mult).round();
597        (scaled.max(1.0) as u64).max(1)
598    }
599
600    /// Mana cost after the mana-cost stones.
601    pub fn apply_mana_cost(&self, mana_cost: f64) -> f64 {
602        (mana_cost * self.mana_cost_mult).max(0.0)
603    }
604
605    /// Cast delay / animation occupation after the cast-time stones. A cast
606    /// that occupied the caster at all keeps occupying it for at least one
607    /// tick.
608    pub fn apply_cast_time(&self, ticks: u64) -> u64 {
609        if ticks == 0 {
610            return 0;
611        }
612        let scaled = (ticks as f64 * self.cast_time_mult).round();
613        (scaled.max(1.0) as u64).max(1)
614    }
615
616    /// The ability's AoE cap after coverage ops. `None` in = no cap
617    /// (unlimited); a multiplier cannot introduce a cap, and Condense always
618    /// collapses to one target.
619    pub fn apply_coverage(&self, max_targets: Option<i64>) -> Option<i64> {
620        if self.condense {
621            return Some(1);
622        }
623        let cap = max_targets?;
624        let scaled = ((cap as f64) * self.coverage_mult).round() as i64 + self.extra_targets;
625        Some(scaled.max(1))
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn identity_mods_change_nothing() {
635        let mods = AbilityStoneMods::identity();
636        assert_eq!(mods.apply_cooldown(10_000), 10_000);
637        assert_eq!(mods.apply_mana_cost(25.0), 25.0);
638        assert_eq!(mods.damage_mult_with_mana(0.9), 1.0);
639        assert_eq!(mods.extra_targets, 0);
640    }
641
642    fn scalar(kind: AbilityStoneOpKind, value: f64) -> AbilityStoneOp {
643        AbilityStoneOp::scalar(kind, value, 0.0)
644    }
645
646    #[test]
647    fn multiplier_ops_fold_multiplicatively() {
648        let mut mods = AbilityStoneMods::identity();
649        // Haste: payload ×0.80, mana ×1.10, cooldown ×0.75.
650        for op in [
651            scalar(AbilityStoneOpKind::PayloadMult, 0.80),
652            scalar(AbilityStoneOpKind::ManaCostMult, 1.10),
653            scalar(AbilityStoneOpKind::CooldownMult, 0.75),
654        ] {
655            mods.apply(&op, op.base_value);
656        }
657
658        assert!((mods.damage_mult - 0.80).abs() < 1e-9);
659        assert_eq!(mods.apply_cooldown(10_000), 7_500);
660        assert!((mods.apply_mana_cost(20.0) - 22.0).abs() < 1e-9);
661    }
662
663    #[test]
664    fn one_stone_carries_several_ops() {
665        // Precision: +25% crit, payload ×0.95, mana ×1.15.
666        let precision = AbilityStoneTemplate {
667            id: Uuid::now_v7(),
668            name: i18n::I18nString::Translated("Precision".to_string()),
669            description: i18n::I18nString::Translated("crit".to_string()),
670            family: AbilityStoneFamily::Critical,
671            compatibility: AbilityStoneCompatibility {
672                required_any_of: vec![AbilityTag::Damage],
673                forbidden: vec![],
674            },
675            ops: vec![
676                scalar(AbilityStoneOpKind::CritChanceBonus, 25.0),
677                scalar(AbilityStoneOpKind::PayloadMult, 0.95),
678                scalar(AbilityStoneOpKind::ManaCostMult, 1.15),
679            ],
680            power_q_first_rank: 1.023,
681            power_q_max_rank: 1.042,
682            icon_path: String::new(),
683        };
684
685        let mut mods = AbilityStoneMods::identity();
686        for op in &precision.ops {
687            mods.apply(op, op.value_at_level(1));
688        }
689
690        assert!((mods.crit_chance_bonus - 0.25).abs() < 1e-9);
691        assert!((mods.damage_mult - 0.95).abs() < 1e-9);
692        assert!((mods.apply_mana_cost(20.0) - 23.0).abs() < 1e-9);
693    }
694
695    #[test]
696    fn missing_mana_stone_scales_with_empty_pool_only() {
697        let mut mods = AbilityStoneMods::identity();
698        let op = scalar(AbilityStoneOpKind::MissingManaDamagePercent, 8.0);
699        mods.apply(&op, op.base_value);
700
701        assert_eq!(mods.damage_mult_with_mana(0.0), 1.0);
702        // 50% missing = five 10%-steps = +40%.
703        assert!((mods.damage_mult_with_mana(0.5) - 1.4).abs() < 1e-9);
704        assert!((mods.damage_mult_with_mana(1.0) - 1.8).abs() < 1e-9);
705    }
706
707    #[test]
708    fn reductions_cannot_invert_cost_or_cooldown() {
709        let mut mods = AbilityStoneMods::identity();
710        for op in [
711            scalar(AbilityStoneOpKind::CooldownMult, -4.0),
712            scalar(AbilityStoneOpKind::ManaCostMult, 0.0),
713        ] {
714            mods.apply(&op, op.base_value);
715        }
716
717        assert_eq!(mods.apply_cooldown(10_000), 1);
718        assert_eq!(mods.apply_mana_cost(30.0), 0.0);
719    }
720
721    #[test]
722    fn rank_scales_magnitude_but_not_the_op_set() {
723        let op = AbilityStoneOp::scalar(AbilityStoneOpKind::PayloadMult, 1.50, 0.10);
724
725        assert!((op.value_at_level(1) - 1.50).abs() < 1e-9);
726        assert!((op.value_at_level(4) - 1.80).abs() < 1e-9);
727        assert_eq!(op.kind, AbilityStoneOpKind::PayloadMult);
728    }
729
730    #[test]
731    fn condense_collapses_coverage_and_widen_multiplies_it() {
732        let mut condense = AbilityStoneMods::identity();
733        let op = scalar(AbilityStoneOpKind::Condense, 1.60);
734        condense.apply(&op, op.base_value);
735        assert_eq!(condense.apply_coverage(Some(3)), Some(1));
736        assert!((condense.damage_mult - 1.60).abs() < 1e-9);
737
738        let mut widen = AbilityStoneMods::identity();
739        for op in [
740            scalar(AbilityStoneOpKind::CoverageMult, 1.60),
741            scalar(AbilityStoneOpKind::PayloadMult, 0.85),
742        ] {
743            widen.apply(&op, op.base_value);
744        }
745        assert_eq!(widen.apply_coverage(Some(3)), Some(5));
746        // An ability with no cap is already unlimited: coverage stays unlimited.
747        assert_eq!(widen.apply_coverage(None), None);
748    }
749
750    #[test]
751    fn pulse_splits_the_payload_across_pulses() {
752        let mut mods = AbilityStoneMods::identity();
753        let op = AbilityStoneOp {
754            kind: AbilityStoneOpKind::PulseSplit,
755            base_value: 45.0,
756            value_per_level: 0.0,
757            count: 3,
758            interval_ms: 400,
759        };
760        mods.apply(&op, op.base_value);
761
762        assert!((mods.damage_mult - 0.45).abs() < 1e-9);
763        assert_eq!(mods.pulse.count, 2);
764        assert_eq!(mods.pulse.interval_ms, 400);
765        assert!(mods.has_delayed_copies());
766    }
767
768    #[test]
769    fn compatibility_matches_the_docs_forms() {
770        let any = AbilityStoneCompatibility::any();
771        assert!(any.accepts(&[]));
772        assert!(any.accepts(&[AbilityTag::Heal]));
773
774        let damage_only = AbilityStoneCompatibility {
775            required_any_of: vec![AbilityTag::Damage],
776            forbidden: vec![],
777        };
778        assert!(damage_only.accepts(&[AbilityTag::Damage, AbilityTag::Aoe]));
779        assert!(!damage_only.accepts(&[AbilityTag::Heal]));
780
781        let any_except_channelled = AbilityStoneCompatibility {
782            required_any_of: vec![],
783            forbidden: vec![AbilityTag::Channelled],
784        };
785        assert!(any_except_channelled.accepts(&[AbilityTag::Heal]));
786        assert!(!any_except_channelled.accepts(&[AbilityTag::Channelled]));
787    }
788
789    #[test]
790    fn sockets_map_tracks_per_ability_placement() {
791        let ability = Uuid::now_v7();
792        let stone = Uuid::now_v7();
793        let mut sockets = AbilityStoneSockets::default();
794
795        sockets.set(ability, 0, stone);
796        assert_eq!(sockets.get(ability, 0), Some(stone));
797        assert!(sockets.is_socketed_in(ability, stone));
798
799        assert_eq!(sockets.clear_socket(ability, 0), Some(stone));
800        assert_eq!(sockets.get(ability, 0), None);
801        assert!(!sockets.is_socketed_in(ability, stone));
802        assert!(sockets.0.is_empty());
803    }
804}