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, 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#[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 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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
307pub struct AbilityStoneDrop {
308 pub stone_id: AbilityStoneId,
309 pub copies: i64,
311 pub is_new: bool,
313}
314
315#[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 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 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
383pub struct DerivedCopies {
384 pub count: i64,
386 pub payload: f64,
390 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#[derive(Clone, Copy, Debug, PartialEq)]
403pub struct AbilityStoneMods {
404 pub damage_mult: f64,
406 pub mana_cost_mult: f64,
408 pub cooldown_mult: f64,
410 pub cast_time_mult: f64,
412 pub effect_duration_mult: f64,
414 pub coverage_mult: f64,
416 pub extra_targets: i64,
418 pub condense: bool,
420 pub crit_chance_bonus: f64,
422 pub missing_mana_damage_per_10pct: f64,
424 pub split: DerivedCopies,
426 pub chain: DerivedCopies,
428 pub pierce: DerivedCopies,
430 pub repeat: DerivedCopies,
432 pub pulse: DerivedCopies,
435 pub leech_fraction: f64,
437 pub guard_next_hit: f64,
439 pub incoming_damage_reduction: f64,
441 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 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 pub fn has_instant_copies(&self) -> bool {
504 self.split.is_active() || self.chain.is_active() || self.pierce.is_active()
505 }
506
507 pub fn has_delayed_copies(&self) -> bool {
510 self.repeat.is_active() || self.pulse.is_active()
511 }
512
513 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 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 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 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 pub fn apply_mana_cost(&self, mana_cost: f64) -> f64 {
604 (mana_cost * self.mana_cost_mult).max(0.0)
605 }
606
607 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 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 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 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 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 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}