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#[declare]
15pub type AbilityStoneSocketIndex = i64;
16
17#[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 #[default]
44 PayloadMult,
45 ManaCostMult,
47 CooldownMult,
49 CastTimeMult,
52 EffectDurationMult,
54 CritChanceBonus,
56 CoverageMult,
60 ExtraTargets,
63 Condense,
66 SplitCopies,
69 ChainTargets,
71 PierceTargets,
74 RepeatCast,
77 PulseSplit,
80 LeechPercent,
82 GuardNextHit,
85 IncomingDamageReduction,
88 MissingManaDamagePercent,
91}
92
93#[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#[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 pub fn any() -> Self {
149 Self::default()
150 }
151
152 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#[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 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 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#[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#[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 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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
305pub struct AbilityStoneDrop {
306 pub stone_id: AbilityStoneId,
307 pub copies: i64,
309 pub is_new: bool,
311}
312
313#[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 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 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
381pub struct DerivedCopies {
382 pub count: i64,
384 pub payload: f64,
388 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#[derive(Clone, Copy, Debug, PartialEq)]
401pub struct AbilityStoneMods {
402 pub damage_mult: f64,
404 pub mana_cost_mult: f64,
406 pub cooldown_mult: f64,
408 pub cast_time_mult: f64,
410 pub effect_duration_mult: f64,
412 pub coverage_mult: f64,
414 pub extra_targets: i64,
416 pub condense: bool,
418 pub crit_chance_bonus: f64,
420 pub missing_mana_damage_per_10pct: f64,
422 pub split: DerivedCopies,
424 pub chain: DerivedCopies,
426 pub pierce: DerivedCopies,
428 pub repeat: DerivedCopies,
430 pub pulse: DerivedCopies,
433 pub leech_fraction: f64,
435 pub guard_next_hit: f64,
437 pub incoming_damage_reduction: f64,
439 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 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 pub fn has_instant_copies(&self) -> bool {
502 self.split.is_active() || self.chain.is_active() || self.pierce.is_active()
503 }
504
505 pub fn has_delayed_copies(&self) -> bool {
508 self.repeat.is_active() || self.pulse.is_active()
509 }
510
511 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 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 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 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 pub fn apply_mana_cost(&self, mana_cost: f64) -> f64 {
602 (mana_cost * self.mana_cost_mult).max(0.0)
603 }
604
605 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 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 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 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 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 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}