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, i18n::Translate)]
215pub struct AbilityStoneTemplate {
216    #[schemars(schema_with = "id_schema")]
217    pub id: AbilityStoneId,
218
219    #[schemars(title = "Название камня способности")]
220    #[translate]
221    pub name: i18n::I18nString,
222
223    #[schemars(title = "Описание камня способности")]
224    #[translate]
225    pub description: i18n::I18nString,
226
227    #[schemars(
228        title = "Семейство исключения",
229        description = "Два камня одного семейства нельзя поставить в один Imprint (два сокета одной стороны одной способности)."
230    )]
231    pub family: AbilityStoneFamily,
232
233    #[schemars(
234        title = "Совместимость",
235        description = "Каким способностям камень подходит, в терминах тегов способности."
236    )]
237    pub compatibility: AbilityStoneCompatibility,
238
239    #[schemars(
240        title = "Операции камня",
241        description = "Один камень несёт набор операций сразу (например payload ×0.80 + mana ×1.10 + cooldown ×0.75)."
242    )]
243    pub ops: Vec<AbilityStoneOp>,
244
245    #[schemars(
246        title = "Множитель Power на первом ранге",
247        description = "Приблизительный вклад в displayed/matchmaking Power (BAL-030). 1.0 = не влияет. Промежуточные ранги интерполируются линейно по ln(q)."
248    )]
249    pub power_q_first_rank: f64,
250
251    #[schemars(
252        title = "Множитель Power на максимальном ранге",
253        description = "Значение того же множителя на последнем ранге. Должен быть не меньше значения на первом."
254    )]
255    pub power_q_max_rank: f64,
256
257    #[schemars(
258        title = "Иконка",
259        schema_with = "schema_loader::asset_ability_stone_icon_schema"
260    )]
261    pub icon_path: String,
262}
263
264/// One owned ability-stone stack. Upgrades are paid in RAW COPIES of the same
265/// stone (the copies' own level is irrelevant), so ownership is a per-template
266/// stack, not a bag of instances.
267#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
268#[tsify(from_wasm_abi)]
269pub struct OwnedAbilityStone {
270    pub template_id: AbilityStoneId,
271    pub level: i64,
272    /// Raw copies held toward the next level.
273    pub copies: i64,
274}
275
276impl OwnedAbilityStone {
277    pub fn new(template_id: AbilityStoneId) -> Self {
278        Self {
279            template_id,
280            level: 1,
281            copies: 0,
282        }
283    }
284}
285
286/// What one `UpgradeAllAbilityStones` call actually changed: template id ->
287/// (from, to) level. Modelled on `UpgradedStonesMap` and used the same way —
288/// the client shows its result window from this.
289///
290/// Only stones that actually gained a level appear; one whose bank was too
291/// short is simply absent rather than present with an unchanged pair.
292#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
293pub struct UpgradedAbilityStonesMap(pub HashMap<AbilityStoneId, (i64, i64)>);
294
295impl UpgradedAbilityStonesMap {
296    pub fn insert(&mut self, id: AbilityStoneId, levels: (i64, i64)) {
297        self.0.insert(id, levels);
298    }
299
300    pub fn is_empty(&self) -> bool {
301        self.0.is_empty()
302    }
303}
304
305/// One granted stone copy, for the client drop popup and quest progression.
306#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
307pub struct AbilityStoneDrop {
308    pub stone_id: AbilityStoneId,
309    /// Copies granted by this drop.
310    pub copies: i64,
311    /// The player owned no copy of this stone before the drop.
312    pub is_new: bool,
313}
314
315/// Socketed ability stones: ability -> socket index -> stone template.
316#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
317#[tsify(from_wasm_abi)]
318pub struct AbilityStoneSockets(
319    pub BTreeMap<AbilityId, BTreeMap<AbilityStoneSocketIndex, AbilityStoneId>>,
320);
321
322impl AbilityStoneSockets {
323    pub fn get(
324        &self,
325        ability_id: AbilityId,
326        socket_index: AbilityStoneSocketIndex,
327    ) -> Option<AbilityStoneId> {
328        self.0
329            .get(&ability_id)
330            .and_then(|sockets| sockets.get(&socket_index))
331            .copied()
332    }
333
334    pub fn set(
335        &mut self,
336        ability_id: AbilityId,
337        socket_index: AbilityStoneSocketIndex,
338        stone_id: AbilityStoneId,
339    ) {
340        self.0
341            .entry(ability_id)
342            .or_default()
343            .insert(socket_index, stone_id);
344    }
345
346    pub fn clear_socket(
347        &mut self,
348        ability_id: AbilityId,
349        socket_index: AbilityStoneSocketIndex,
350    ) -> Option<AbilityStoneId> {
351        let sockets = self.0.get_mut(&ability_id)?;
352        let removed = sockets.remove(&socket_index);
353        if sockets.is_empty() {
354            self.0.remove(&ability_id);
355        }
356        removed
357    }
358
359    /// Every stone socketed into `ability_id`, in socket order.
360    pub fn sockets_of(
361        &self,
362        ability_id: AbilityId,
363    ) -> impl Iterator<Item = (AbilityStoneSocketIndex, AbilityStoneId)> + '_ {
364        self.0
365            .get(&ability_id)
366            .into_iter()
367            .flat_map(|sockets| sockets.iter().map(|(index, stone)| (*index, *stone)))
368    }
369
370    /// Whether `stone_id` already occupies a socket of `ability_id`. A stone
371    /// template may hold at most one socket per ability (stub rule: it stops a
372    /// single copy from filling all four sockets of one ability).
373    pub fn is_socketed_in(&self, ability_id: AbilityId, stone_id: AbilityStoneId) -> bool {
374        self.sockets_of(ability_id).any(|(_, id)| id == stone_id)
375    }
376}
377
378/// One derived-copy shape produced by a support: `count` extra hits at
379/// `payload` of the ability's payload. Derived hits are NOT Core events — they
380/// never re-run `on_cast`, never charge the pet bar and are tagged `derived` in
381/// their damage payload so no future law/trigger/mastery reader counts them.
382#[derive(Clone, Copy, Debug, Default, PartialEq)]
383pub struct DerivedCopies {
384    /// How many extra hits.
385    pub count: i64,
386    /// Payload share of each extra hit (0.70 = 70% of the ability's payload).
387    /// For [`AbilityStoneOpKind::PierceTargets`] this is the falloff applied
388    /// cumulatively (hit 1 = 0.70, hit 2 = 0.49, ...).
389    pub payload: f64,
390    /// Spacing/delay in ms; 0 = same tick as the original hit.
391    pub interval_ms: u64,
392}
393
394impl DerivedCopies {
395    pub fn is_active(&self) -> bool {
396        self.count > 0 && self.payload > 0.0
397    }
398}
399
400/// Aggregated numeric modifiers of the stones ACTIVE on one ability — the two
401/// sockets whose side matches the currently active [`crate::flip::WorldSide`].
402#[derive(Clone, Copy, Debug, PartialEq)]
403pub struct AbilityStoneMods {
404    /// Multiplier on every damage component.
405    pub damage_mult: f64,
406    /// Multiplier on the mana cost.
407    pub mana_cost_mult: f64,
408    /// Multiplier on the cooldown.
409    pub cooldown_mult: f64,
410    /// Multiplier on the cast's delay + animation occupation.
411    pub cast_time_mult: f64,
412    /// Multiplier on applied-effect duration.
413    pub effect_duration_mult: f64,
414    /// Multiplier on the AoE target cap.
415    pub coverage_mult: f64,
416    /// Extra AoE targets (additive, applied after `coverage_mult`).
417    pub extra_targets: i64,
418    /// Coverage collapses to a single target and copies fold into it.
419    pub condense: bool,
420    /// Added crit chance, as a fraction (0.25 = +25 percent points).
421    pub crit_chance_bonus: f64,
422    /// Extra damage fraction per 10% of missing mana.
423    pub missing_mana_damage_per_10pct: f64,
424    /// Extra instant copies on the same target (Split).
425    pub split: DerivedCopies,
426    /// Extra jumps to further targets (Chain).
427    pub chain: DerivedCopies,
428    /// Extra pierced targets with cumulative falloff (Pierce).
429    pub pierce: DerivedCopies,
430    /// Delayed derived repeats of the whole cast (Repeat).
431    pub repeat: DerivedCopies,
432    /// Extra delayed pulses of the cast (Pulse). The original cast is pulse #1,
433    /// so `count` here is the number of EXTRA pulses.
434    pub pulse: DerivedCopies,
435    /// Share of the final damage that heals the caster (Leech).
436    pub leech_fraction: f64,
437    /// Damage reduction of the next incoming hit (Fortify), as a fraction.
438    pub guard_next_hit: f64,
439    /// Incoming-damage reduction after the cast (Shelter), as a fraction.
440    pub incoming_damage_reduction: f64,
441    /// How long the Shelter window lasts, in ms.
442    pub incoming_damage_reduction_ms: u64,
443}
444
445impl Default for AbilityStoneMods {
446    fn default() -> Self {
447        Self::identity()
448    }
449}
450
451impl AbilityStoneMods {
452    /// The no-stones case: every ability behaves exactly as before.
453    pub const fn identity() -> Self {
454        Self {
455            damage_mult: 1.0,
456            mana_cost_mult: 1.0,
457            cooldown_mult: 1.0,
458            cast_time_mult: 1.0,
459            effect_duration_mult: 1.0,
460            coverage_mult: 1.0,
461            extra_targets: 0,
462            condense: false,
463            crit_chance_bonus: 0.0,
464            missing_mana_damage_per_10pct: 0.0,
465            split: DerivedCopies {
466                count: 0,
467                payload: 0.0,
468                interval_ms: 0,
469            },
470            chain: DerivedCopies {
471                count: 0,
472                payload: 0.0,
473                interval_ms: 0,
474            },
475            pierce: DerivedCopies {
476                count: 0,
477                payload: 0.0,
478                interval_ms: 0,
479            },
480            repeat: DerivedCopies {
481                count: 0,
482                payload: 0.0,
483                interval_ms: 0,
484            },
485            pulse: DerivedCopies {
486                count: 0,
487                payload: 0.0,
488                interval_ms: 0,
489            },
490            leech_fraction: 0.0,
491            guard_next_hit: 0.0,
492            incoming_damage_reduction: 0.0,
493            incoming_damage_reduction_ms: 0,
494        }
495    }
496
497    pub fn is_identity(&self) -> bool {
498        *self == Self::identity()
499    }
500
501    /// Whether any op produces extra hits that resolve at the attack seam
502    /// (Split / Chain / Pierce).
503    pub fn has_instant_copies(&self) -> bool {
504        self.split.is_active() || self.chain.is_active() || self.pierce.is_active()
505    }
506
507    /// Whether any op produces hits that resolve on a later tick
508    /// (Repeat / Pulse).
509    pub fn has_delayed_copies(&self) -> bool {
510        self.repeat.is_active() || self.pulse.is_active()
511    }
512
513    /// Folds one op in. Percent reductions are clamped so a stack of stones can
514    /// never invert a cost or a cooldown.
515    pub fn apply(&mut self, op: &AbilityStoneOp, value: f64) {
516        if !value.is_finite() {
517            return;
518        }
519        let copies = |payload: f64| DerivedCopies {
520            count: op.count.max(0),
521            payload: (payload / 100.0).max(0.0),
522            interval_ms: op.interval_ms.max(0) as u64,
523        };
524        match op.kind {
525            AbilityStoneOpKind::PayloadMult => {
526                self.damage_mult = (self.damage_mult * value).max(0.0);
527            }
528            AbilityStoneOpKind::ManaCostMult => {
529                self.mana_cost_mult = (self.mana_cost_mult * value).max(0.0);
530            }
531            AbilityStoneOpKind::CooldownMult => {
532                self.cooldown_mult = (self.cooldown_mult * value).max(0.0);
533            }
534            AbilityStoneOpKind::CastTimeMult => {
535                self.cast_time_mult = (self.cast_time_mult * value).max(0.0);
536            }
537            AbilityStoneOpKind::EffectDurationMult => {
538                self.effect_duration_mult = (self.effect_duration_mult * value).max(0.0);
539            }
540            AbilityStoneOpKind::CritChanceBonus => {
541                self.crit_chance_bonus += value / 100.0;
542            }
543            AbilityStoneOpKind::CoverageMult => {
544                self.coverage_mult = (self.coverage_mult * value).max(0.0);
545            }
546            AbilityStoneOpKind::ExtraTargets => {
547                self.extra_targets += value.round() as i64;
548            }
549            AbilityStoneOpKind::Condense => {
550                self.condense = true;
551                self.damage_mult = (self.damage_mult * value).max(0.0);
552            }
553            AbilityStoneOpKind::SplitCopies => self.split = copies(value),
554            AbilityStoneOpKind::ChainTargets => self.chain = copies(value),
555            AbilityStoneOpKind::PierceTargets => self.pierce = copies(value),
556            AbilityStoneOpKind::RepeatCast => self.repeat = copies(value),
557            AbilityStoneOpKind::PulseSplit => {
558                // The original cast is pulse #1: it carries the per-pulse
559                // payload, and `count - 1` derived pulses follow it.
560                let payload = (value / 100.0).max(0.0);
561                self.damage_mult = (self.damage_mult * payload).max(0.0);
562                self.pulse = DerivedCopies {
563                    count: (op.count - 1).max(0),
564                    payload: 1.0,
565                    interval_ms: op.interval_ms.max(0) as u64,
566                };
567            }
568            AbilityStoneOpKind::LeechPercent => {
569                self.leech_fraction += (value / 100.0).max(0.0);
570            }
571            AbilityStoneOpKind::GuardNextHit => {
572                self.guard_next_hit = (self.guard_next_hit + value / 100.0).clamp(0.0, 0.95);
573            }
574            AbilityStoneOpKind::IncomingDamageReduction => {
575                self.incoming_damage_reduction =
576                    (self.incoming_damage_reduction + value / 100.0).clamp(0.0, 0.95);
577                self.incoming_damage_reduction_ms = op.interval_ms.max(0) as u64;
578            }
579            AbilityStoneOpKind::MissingManaDamagePercent => {
580                self.missing_mana_damage_per_10pct += value / 100.0;
581            }
582        }
583    }
584
585    /// Damage multiplier including the missing-mana term ("Отдача"), which is
586    /// resolved at cast time against the caster's pool.
587    pub fn damage_mult_with_mana(&self, missing_mana_fraction: f64) -> f64 {
588        let missing_tenths = (missing_mana_fraction.clamp(0.0, 1.0) * 10.0).floor();
589        (self.damage_mult + self.missing_mana_damage_per_10pct * missing_tenths).max(0.0)
590    }
591
592    /// Cooldown in ticks after the cooldown stones, never shorter than one tick
593    /// for an ability that has a cooldown at all.
594    pub fn apply_cooldown(&self, cooldown_ticks: u64) -> u64 {
595        if cooldown_ticks == 0 {
596            return 0;
597        }
598        let scaled = (cooldown_ticks as f64 * self.cooldown_mult).round();
599        (scaled.max(1.0) as u64).max(1)
600    }
601
602    /// Mana cost after the mana-cost stones.
603    pub fn apply_mana_cost(&self, mana_cost: f64) -> f64 {
604        (mana_cost * self.mana_cost_mult).max(0.0)
605    }
606
607    /// Cast delay / animation occupation after the cast-time stones. A cast
608    /// that occupied the caster at all keeps occupying it for at least one
609    /// tick.
610    pub fn apply_cast_time(&self, ticks: u64) -> u64 {
611        if ticks == 0 {
612            return 0;
613        }
614        let scaled = (ticks as f64 * self.cast_time_mult).round();
615        (scaled.max(1.0) as u64).max(1)
616    }
617
618    /// The ability's AoE cap after coverage ops. `None` in = no cap
619    /// (unlimited); a multiplier cannot introduce a cap, and Condense always
620    /// collapses to one target.
621    pub fn apply_coverage(&self, max_targets: Option<i64>) -> Option<i64> {
622        if self.condense {
623            return Some(1);
624        }
625        let cap = max_targets?;
626        let scaled = ((cap as f64) * self.coverage_mult).round() as i64 + self.extra_targets;
627        Some(scaled.max(1))
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn identity_mods_change_nothing() {
637        let mods = AbilityStoneMods::identity();
638        assert_eq!(mods.apply_cooldown(10_000), 10_000);
639        assert_eq!(mods.apply_mana_cost(25.0), 25.0);
640        assert_eq!(mods.damage_mult_with_mana(0.9), 1.0);
641        assert_eq!(mods.extra_targets, 0);
642    }
643
644    fn scalar(kind: AbilityStoneOpKind, value: f64) -> AbilityStoneOp {
645        AbilityStoneOp::scalar(kind, value, 0.0)
646    }
647
648    #[test]
649    fn multiplier_ops_fold_multiplicatively() {
650        let mut mods = AbilityStoneMods::identity();
651        // Haste: payload ×0.80, mana ×1.10, cooldown ×0.75.
652        for op in [
653            scalar(AbilityStoneOpKind::PayloadMult, 0.80),
654            scalar(AbilityStoneOpKind::ManaCostMult, 1.10),
655            scalar(AbilityStoneOpKind::CooldownMult, 0.75),
656        ] {
657            mods.apply(&op, op.base_value);
658        }
659
660        assert!((mods.damage_mult - 0.80).abs() < 1e-9);
661        assert_eq!(mods.apply_cooldown(10_000), 7_500);
662        assert!((mods.apply_mana_cost(20.0) - 22.0).abs() < 1e-9);
663    }
664
665    #[test]
666    fn one_stone_carries_several_ops() {
667        // Precision: +25% crit, payload ×0.95, mana ×1.15.
668        let precision = AbilityStoneTemplate {
669            id: Uuid::now_v7(),
670            name: i18n::I18nString::Translated("Precision".to_string()),
671            description: i18n::I18nString::Translated("crit".to_string()),
672            family: AbilityStoneFamily::Critical,
673            compatibility: AbilityStoneCompatibility {
674                required_any_of: vec![AbilityTag::Damage],
675                forbidden: vec![],
676            },
677            ops: vec![
678                scalar(AbilityStoneOpKind::CritChanceBonus, 25.0),
679                scalar(AbilityStoneOpKind::PayloadMult, 0.95),
680                scalar(AbilityStoneOpKind::ManaCostMult, 1.15),
681            ],
682            power_q_first_rank: 1.023,
683            power_q_max_rank: 1.042,
684            icon_path: String::new(),
685        };
686
687        let mut mods = AbilityStoneMods::identity();
688        for op in &precision.ops {
689            mods.apply(op, op.value_at_level(1));
690        }
691
692        assert!((mods.crit_chance_bonus - 0.25).abs() < 1e-9);
693        assert!((mods.damage_mult - 0.95).abs() < 1e-9);
694        assert!((mods.apply_mana_cost(20.0) - 23.0).abs() < 1e-9);
695    }
696
697    #[test]
698    fn missing_mana_stone_scales_with_empty_pool_only() {
699        let mut mods = AbilityStoneMods::identity();
700        let op = scalar(AbilityStoneOpKind::MissingManaDamagePercent, 8.0);
701        mods.apply(&op, op.base_value);
702
703        assert_eq!(mods.damage_mult_with_mana(0.0), 1.0);
704        // 50% missing = five 10%-steps = +40%.
705        assert!((mods.damage_mult_with_mana(0.5) - 1.4).abs() < 1e-9);
706        assert!((mods.damage_mult_with_mana(1.0) - 1.8).abs() < 1e-9);
707    }
708
709    #[test]
710    fn reductions_cannot_invert_cost_or_cooldown() {
711        let mut mods = AbilityStoneMods::identity();
712        for op in [
713            scalar(AbilityStoneOpKind::CooldownMult, -4.0),
714            scalar(AbilityStoneOpKind::ManaCostMult, 0.0),
715        ] {
716            mods.apply(&op, op.base_value);
717        }
718
719        assert_eq!(mods.apply_cooldown(10_000), 1);
720        assert_eq!(mods.apply_mana_cost(30.0), 0.0);
721    }
722
723    #[test]
724    fn rank_scales_magnitude_but_not_the_op_set() {
725        let op = AbilityStoneOp::scalar(AbilityStoneOpKind::PayloadMult, 1.50, 0.10);
726
727        assert!((op.value_at_level(1) - 1.50).abs() < 1e-9);
728        assert!((op.value_at_level(4) - 1.80).abs() < 1e-9);
729        assert_eq!(op.kind, AbilityStoneOpKind::PayloadMult);
730    }
731
732    #[test]
733    fn condense_collapses_coverage_and_widen_multiplies_it() {
734        let mut condense = AbilityStoneMods::identity();
735        let op = scalar(AbilityStoneOpKind::Condense, 1.60);
736        condense.apply(&op, op.base_value);
737        assert_eq!(condense.apply_coverage(Some(3)), Some(1));
738        assert!((condense.damage_mult - 1.60).abs() < 1e-9);
739
740        let mut widen = AbilityStoneMods::identity();
741        for op in [
742            scalar(AbilityStoneOpKind::CoverageMult, 1.60),
743            scalar(AbilityStoneOpKind::PayloadMult, 0.85),
744        ] {
745            widen.apply(&op, op.base_value);
746        }
747        assert_eq!(widen.apply_coverage(Some(3)), Some(5));
748        // An ability with no cap is already unlimited: coverage stays unlimited.
749        assert_eq!(widen.apply_coverage(None), None);
750    }
751
752    #[test]
753    fn pulse_splits_the_payload_across_pulses() {
754        let mut mods = AbilityStoneMods::identity();
755        let op = AbilityStoneOp {
756            kind: AbilityStoneOpKind::PulseSplit,
757            base_value: 45.0,
758            value_per_level: 0.0,
759            count: 3,
760            interval_ms: 400,
761        };
762        mods.apply(&op, op.base_value);
763
764        assert!((mods.damage_mult - 0.45).abs() < 1e-9);
765        assert_eq!(mods.pulse.count, 2);
766        assert_eq!(mods.pulse.interval_ms, 400);
767        assert!(mods.has_delayed_copies());
768    }
769
770    #[test]
771    fn compatibility_matches_the_docs_forms() {
772        let any = AbilityStoneCompatibility::any();
773        assert!(any.accepts(&[]));
774        assert!(any.accepts(&[AbilityTag::Heal]));
775
776        let damage_only = AbilityStoneCompatibility {
777            required_any_of: vec![AbilityTag::Damage],
778            forbidden: vec![],
779        };
780        assert!(damage_only.accepts(&[AbilityTag::Damage, AbilityTag::Aoe]));
781        assert!(!damage_only.accepts(&[AbilityTag::Heal]));
782
783        let any_except_channelled = AbilityStoneCompatibility {
784            required_any_of: vec![],
785            forbidden: vec![AbilityTag::Channelled],
786        };
787        assert!(any_except_channelled.accepts(&[AbilityTag::Heal]));
788        assert!(!any_except_channelled.accepts(&[AbilityTag::Channelled]));
789    }
790
791    #[test]
792    fn sockets_map_tracks_per_ability_placement() {
793        let ability = Uuid::now_v7();
794        let stone = Uuid::now_v7();
795        let mut sockets = AbilityStoneSockets::default();
796
797        sockets.set(ability, 0, stone);
798        assert_eq!(sockets.get(ability, 0), Some(stone));
799        assert!(sockets.is_socketed_in(ability, stone));
800
801        assert_eq!(sockets.clear_socket(ability, 0), Some(stone));
802        assert_eq!(sockets.get(ability, 0), None);
803        assert!(!sockets.is_socketed_in(ability, stone));
804        assert!(sockets.0.is_empty());
805    }
806}