Skip to main content

workshop_rs/gameplay/
query.rs

1//! Deterministic queries, locale resolution, and small calculations over
2//! [`crate::gameplay`]. This layer consumes validated records and never
3//! invents missing facts or display-name aliases.
4
5use std::cmp::Ordering;
6
7use crate::gameplay::{
8    Ability, AbilityRef, AbilityVariant, Fact, GameplayCatalog, Hero, HeroId, LogicalSlot,
9    Quantity, StatKey, StatValue, Unit, units,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct AbilityMatch<'a> {
14    pub hero: &'a Hero,
15    pub ability: &'a Ability,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum StatOwner {
20    Hero(HeroId),
21    Ability { reference: AbilityRef },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum GameplayQueryError {
27    MissingHero {
28        hero: HeroId,
29    },
30    MissingAbility {
31        reference: AbilityRef,
32    },
33    MissingSlot {
34        hero: HeroId,
35        slot: LogicalSlot,
36    },
37    AmbiguousSlot {
38        hero: HeroId,
39        slot: LogicalSlot,
40        candidates: Vec<AbilityRef>,
41    },
42    MissingVariant {
43        hero: HeroId,
44        slot: LogicalSlot,
45        variant: AbilityVariant,
46    },
47    MissingStat {
48        owner: StatOwner,
49        stat: StatKey,
50    },
51    WrongStatType {
52        owner: StatOwner,
53        stat: StatKey,
54        expected: &'static str,
55    },
56}
57
58impl std::fmt::Display for GameplayQueryError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::MissingHero { hero } => write!(f, "gameplay catalog has no hero '{hero}'"),
62            Self::MissingAbility { reference } => {
63                write!(f, "no ability for canonical reference {reference:?}")
64            }
65            Self::MissingSlot { hero, slot } => {
66                write!(f, "hero '{hero}' has no ability in slot '{slot}'")
67            }
68            Self::AmbiguousSlot {
69                hero,
70                slot,
71                candidates,
72            } => write!(
73                f,
74                "hero '{hero}' has multiple abilities in slot '{slot}': {candidates:?}"
75            ),
76            Self::MissingVariant {
77                hero,
78                slot,
79                variant,
80            } => write!(
81                f,
82                "hero '{hero}' has no ability in slot '{slot}' with variant '{variant}'"
83            ),
84            Self::MissingStat { owner, stat } => write!(f, "{owner:?} has no stat '{stat}'"),
85            Self::WrongStatType {
86                owner,
87                stat,
88                expected,
89            } => write!(f, "{owner:?} stat '{stat}' is not a {expected}"),
90        }
91    }
92}
93impl std::error::Error for GameplayQueryError {}
94
95/// Explicit failures from locale-aware display-name resolution.
96#[derive(Debug, Clone, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum AbilityNameResolutionError {
99    MissingHero {
100        hero: HeroId,
101    },
102    MissingSlot {
103        hero: HeroId,
104        slot: LogicalSlot,
105    },
106    MissingVariant {
107        reference: AbilityRef,
108    },
109    AmbiguousSlot {
110        hero: HeroId,
111        slot: LogicalSlot,
112        candidates: Vec<AbilityRef>,
113    },
114    UnsupportedLocale {
115        locale: String,
116    },
117    MissingName {
118        reference: AbilityRef,
119        locale: String,
120    },
121    MissingDisplayName {
122        hero: HeroId,
123        locale: String,
124        name: String,
125    },
126    AmbiguousName {
127        hero: HeroId,
128        locale: String,
129        name: String,
130        candidates: Vec<AbilityRef>,
131    },
132}
133
134impl std::fmt::Display for AbilityNameResolutionError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::MissingHero { hero } => write!(f, "gameplay catalog has no hero '{hero}'"),
138            Self::MissingSlot { hero, slot } => {
139                write!(f, "hero '{hero}' has no ability in slot '{slot}'")
140            }
141            Self::MissingVariant { reference } => {
142                write!(f, "no ability for canonical reference {reference:?}")
143            }
144            Self::AmbiguousSlot {
145                hero,
146                slot,
147                candidates,
148            } => write!(
149                f,
150                "hero '{hero}' has multiple abilities in slot '{slot}': {candidates:?}"
151            ),
152            Self::UnsupportedLocale { locale } => write!(
153                f,
154                "locale '{locale}' is unsupported by the gameplay name data"
155            ),
156            Self::MissingName { reference, locale } => write!(
157                f,
158                "ability {reference:?} has no source-backed name for locale '{locale}'"
159            ),
160            Self::MissingDisplayName { hero, locale, name } => write!(
161                f,
162                "hero '{hero}' has no ability named '{name}' for locale '{locale}'"
163            ),
164            Self::AmbiguousName {
165                hero,
166                locale,
167                name,
168                candidates,
169            } => write!(
170                f,
171                "hero '{hero}' has multiple abilities named '{name}' for locale '{locale}': {candidates:?}"
172            ),
173        }
174    }
175}
176impl std::error::Error for AbilityNameResolutionError {}
177
178pub const MIN_CUSTOM_GAME_COOLDOWN_PERCENTAGE: f64 = 0.0;
179pub const MAX_CUSTOM_GAME_COOLDOWN_PERCENTAGE: f64 = 500.0;
180
181#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
182pub struct CooldownPercentage(f64);
183
184impl CooldownPercentage {
185    pub fn new(value: f64) -> Result<Self, CooldownPercentageError> {
186        if !value.is_finite() {
187            return Err(CooldownPercentageError::NotFinite { value });
188        }
189        if !(MIN_CUSTOM_GAME_COOLDOWN_PERCENTAGE..=MAX_CUSTOM_GAME_COOLDOWN_PERCENTAGE)
190            .contains(&value)
191        {
192            return Err(CooldownPercentageError::OutOfRange { value });
193        }
194        Ok(Self(value))
195    }
196    pub fn value(self) -> f64 {
197        self.0
198    }
199}
200impl TryFrom<f64> for CooldownPercentage {
201    type Error = CooldownPercentageError;
202    fn try_from(value: f64) -> Result<Self, Self::Error> {
203        Self::new(value)
204    }
205}
206
207#[derive(Debug, Clone, PartialEq)]
208#[non_exhaustive]
209pub enum CooldownPercentageError {
210    NotFinite { value: f64 },
211    OutOfRange { value: f64 },
212}
213impl std::fmt::Display for CooldownPercentageError {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        match self {
216            Self::NotFinite { value } => write!(f, "cooldown percentage '{value}' is not finite"),
217            Self::OutOfRange { value } => write!(
218                f,
219                "cooldown percentage '{value}' is outside {MIN_CUSTOM_GAME_COOLDOWN_PERCENTAGE}%..={MAX_CUSTOM_GAME_COOLDOWN_PERCENTAGE}%"
220            ),
221        }
222    }
223}
224impl std::error::Error for CooldownPercentageError {}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum CooldownNonApplicability {
228    WrongValueType,
229    WrongUnit { actual: Unit },
230    NonPositiveBase,
231}
232
233#[derive(Debug, Clone, PartialEq)]
234#[non_exhaustive]
235pub enum CooldownError {
236    Missing {
237        ability: AbilityRef,
238    },
239    NonApplicable {
240        ability: AbilityRef,
241        reason: CooldownNonApplicability,
242    },
243    InvalidBase {
244        ability: AbilityRef,
245        value: f64,
246    },
247    InvalidTarget {
248        value: f64,
249    },
250    TargetWrongUnit {
251        actual: Unit,
252    },
253    InvalidPercentage(CooldownPercentageError),
254    CalculationOverflow {
255        ability: AbilityRef,
256    },
257    MissingAbility {
258        reference: AbilityRef,
259    },
260}
261impl std::fmt::Display for CooldownError {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        match self {
264            Self::Missing { ability } => write!(f, "ability {ability:?} has no cooldown stat"),
265            Self::NonApplicable { ability, reason } => write!(
266                f,
267                "ability {ability:?} cooldown is not applicable: {reason:?}"
268            ),
269            Self::InvalidBase { ability, value } => write!(
270                f,
271                "ability {ability:?} cooldown base '{value}' is not finite"
272            ),
273            Self::InvalidTarget { value } => write!(
274                f,
275                "target cooldown '{value}' must be finite and non-negative"
276            ),
277            Self::TargetWrongUnit { actual } => {
278                write!(f, "target cooldown has unit '{actual}', expected 'seconds'")
279            }
280            Self::InvalidPercentage(error) => error.fmt(f),
281            Self::CalculationOverflow { ability } => write!(
282                f,
283                "cooldown calculation for ability {ability:?} is not finite"
284            ),
285            Self::MissingAbility { reference } => {
286                write!(f, "no ability for canonical reference {reference:?}")
287            }
288        }
289    }
290}
291impl std::error::Error for CooldownError {}
292
293enum SlotMiss<'a> {
294    Missing,
295    Ambiguous(Vec<&'a Ability>),
296}
297
298fn match_slot<'a>(hero: &'a Hero, slot: &LogicalSlot) -> Result<&'a Ability, SlotMiss<'a>> {
299    let mut matches = hero.abilities_in_slot(slot);
300    match matches.len() {
301        0 => Err(SlotMiss::Missing),
302        1 => Ok(matches.pop().expect("length checked")),
303        _ => Err(SlotMiss::Ambiguous(matches)),
304    }
305}
306
307#[derive(Debug, Clone, Copy)]
308pub struct GameplayQuery<'a> {
309    catalog: &'a GameplayCatalog,
310}
311impl GameplayCatalog {
312    pub fn query(&self) -> GameplayQuery<'_> {
313        GameplayQuery { catalog: self }
314    }
315}
316
317impl<'a> GameplayQuery<'a> {
318    pub fn heroes(&self) -> &'a [Hero] {
319        self.catalog.heroes()
320    }
321
322    pub fn hero(&self, hero: impl AsRef<str>) -> Result<&'a Hero, GameplayQueryError> {
323        self.find_hero(hero.as_ref())
324            .map_err(|hero| GameplayQueryError::MissingHero { hero })
325    }
326
327    pub fn kit(&self, hero: impl AsRef<str>) -> Result<Vec<&'a Ability>, GameplayQueryError> {
328        let hero = self.hero(hero)?;
329        let mut abilities = hero.abilities().iter().collect::<Vec<_>>();
330        abilities.sort_by(|left, right| ability_order(left, right));
331        Ok(abilities)
332    }
333
334    pub fn slot(
335        &self,
336        hero: impl AsRef<str>,
337        slot: impl AsRef<str>,
338    ) -> Result<Vec<&'a Ability>, GameplayQueryError> {
339        let hero = self.hero(hero)?;
340        let slot = LogicalSlot::new(slot.as_ref());
341        let mut abilities = hero.abilities_in_slot(&slot);
342        if abilities.is_empty() {
343            return Err(GameplayQueryError::MissingSlot {
344                hero: hero.id().clone(),
345                slot,
346            });
347        }
348        abilities.sort_by(|left, right| ability_order(left, right));
349        Ok(abilities)
350    }
351
352    pub fn ability(
353        &self,
354        hero: impl AsRef<str>,
355        slot: impl AsRef<str>,
356    ) -> Result<&'a Ability, GameplayQueryError> {
357        let hero = self.hero(hero)?;
358        self.unique_slot(hero, LogicalSlot::new(slot.as_ref()))
359    }
360
361    pub fn ability_ref(&self, reference: &AbilityRef) -> Result<&'a Ability, GameplayQueryError> {
362        self.catalog
363            .ability(reference)
364            .map_err(|error| match error {
365                crate::gameplay::AbilityLookupError::Missing { .. }
366                | crate::gameplay::AbilityLookupError::MissingVariant { .. } => {
367                    GameplayQueryError::MissingAbility {
368                        reference: reference.clone(),
369                    }
370                }
371                crate::gameplay::AbilityLookupError::Ambiguous {
372                    hero,
373                    slot,
374                    candidates,
375                } => GameplayQueryError::AmbiguousSlot {
376                    hero,
377                    slot,
378                    candidates,
379                },
380            })
381    }
382
383    pub fn slot_ability(
384        &self,
385        hero: impl AsRef<str>,
386        slot: impl AsRef<str>,
387    ) -> Result<&'a Ability, GameplayQueryError> {
388        self.ability(hero, slot)
389    }
390
391    pub fn variant(
392        &self,
393        hero: impl AsRef<str>,
394        slot: impl AsRef<str>,
395        variant: impl AsRef<str>,
396    ) -> Result<&'a Ability, GameplayQueryError> {
397        let hero = self.hero(hero)?;
398        let slot = LogicalSlot::new(slot.as_ref());
399        let variant = AbilityVariant::new(variant.as_ref());
400        hero.ability_variant(&slot, &variant)
401            .map_err(|_| GameplayQueryError::MissingVariant {
402                hero: hero.id().clone(),
403                slot,
404                variant,
405            })
406    }
407
408    pub fn keyword(&self, keyword: impl AsRef<str>) -> Vec<AbilityMatch<'a>> {
409        let mut matches = self
410            .catalog
411            .heroes()
412            .iter()
413            .flat_map(|hero| {
414                hero.abilities()
415                    .iter()
416                    .filter(|ability| ability.has_keyword(keyword.as_ref()))
417                    .map(move |ability| AbilityMatch { hero, ability })
418            })
419            .collect::<Vec<_>>();
420        matches.sort_by(|left, right| {
421            left.hero
422                .id()
423                .cmp(right.hero.id())
424                .then_with(|| ability_order(left.ability, right.ability))
425        });
426        matches
427    }
428
429    pub fn hero_stat(
430        &self,
431        hero: impl AsRef<str>,
432        stat: impl AsRef<str>,
433    ) -> Result<&'a Fact<StatValue>, GameplayQueryError> {
434        let hero = self.hero(hero)?;
435        let stat = StatKey::new(stat.as_ref());
436        hero.stat(&stat)
437            .ok_or_else(|| GameplayQueryError::MissingStat {
438                owner: StatOwner::Hero(hero.id().clone()),
439                stat,
440            })
441    }
442
443    pub fn stat(
444        &self,
445        hero: impl AsRef<str>,
446        slot: impl AsRef<str>,
447        variant: Option<&AbilityVariant>,
448        stat: impl AsRef<str>,
449    ) -> Result<&'a Fact<StatValue>, GameplayQueryError> {
450        let reference = self.reference(hero, slot, variant)?;
451        self.ability_stat(&reference, StatKey::new(stat.as_ref()))
452    }
453
454    pub fn quantity_stat(
455        &self,
456        hero: impl AsRef<str>,
457        slot: impl AsRef<str>,
458        variant: Option<&AbilityVariant>,
459        stat: impl AsRef<str>,
460    ) -> Result<&'a Quantity, GameplayQueryError> {
461        let reference = self.reference(hero, slot, variant)?;
462        let stat_key = StatKey::new(stat.as_ref());
463        let fact = self.ability_stat(&reference, stat_key.clone())?;
464        match fact.value() {
465            StatValue::Quantity(quantity) => Ok(quantity),
466            _ => Err(GameplayQueryError::WrongStatType {
467                owner: StatOwner::Ability { reference },
468                stat: stat_key,
469                expected: "quantity",
470            }),
471        }
472    }
473
474    pub fn ability_name(
475        &self,
476        hero: impl AsRef<str>,
477        slot: impl AsRef<str>,
478        variant: Option<&AbilityVariant>,
479        locale: impl AsRef<str>,
480    ) -> Result<&'a str, AbilityNameResolutionError> {
481        let reference = self.reference_for_names(hero, slot, variant)?;
482        let locale = locale.as_ref();
483        let ability = self.catalog.ability(&reference).map_err(|_| {
484            AbilityNameResolutionError::MissingVariant {
485                reference: reference.clone(),
486            }
487        })?;
488        if let Some(name) = ability.name().value().get(locale) {
489            return Ok(name);
490        }
491        if self
492            .catalog
493            .heroes()
494            .iter()
495            .flat_map(|hero| hero.abilities())
496            .any(|ability| ability.name().value().get(locale).is_some())
497        {
498            return Err(AbilityNameResolutionError::MissingName {
499                reference,
500                locale: locale.to_string(),
501            });
502        }
503        Err(AbilityNameResolutionError::UnsupportedLocale {
504            locale: locale.to_string(),
505        })
506    }
507
508    pub fn resolve_ability_name(
509        &self,
510        hero: impl AsRef<str>,
511        locale: impl AsRef<str>,
512        name: impl AsRef<str>,
513    ) -> Result<AbilityRef, AbilityNameResolutionError> {
514        let hero = self.hero_for_names(hero)?;
515        let locale = locale.as_ref();
516        let name = name.as_ref();
517        let supported = hero
518            .abilities()
519            .iter()
520            .any(|ability| ability.name().value().get(locale).is_some());
521        if !supported {
522            return Err(AbilityNameResolutionError::UnsupportedLocale {
523                locale: locale.to_string(),
524            });
525        }
526        let matches = hero
527            .abilities()
528            .iter()
529            .filter(|ability| ability.name().value().get(locale) == Some(name))
530            .map(|ability| ability.reference(hero.id()))
531            .collect::<Vec<_>>();
532        match matches.as_slice() {
533            [] => Err(AbilityNameResolutionError::MissingDisplayName {
534                hero: hero.id().clone(),
535                locale: locale.to_string(),
536                name: name.to_string(),
537            }),
538            [reference] => Ok(reference.clone()),
539            _ => Err(AbilityNameResolutionError::AmbiguousName {
540                hero: hero.id().clone(),
541                locale: locale.to_string(),
542                name: name.to_string(),
543                candidates: matches,
544            }),
545        }
546    }
547
548    pub fn cooldown(&self, reference: &AbilityRef) -> Result<&'a Quantity, CooldownError> {
549        let ability = self
550            .ability_ref(reference)
551            .map_err(|_| CooldownError::MissingAbility {
552                reference: reference.clone(),
553            })?;
554        self.cooldown_value(reference, ability)
555    }
556
557    pub fn effective_cooldown(
558        &self,
559        reference: &AbilityRef,
560        percentage: CooldownPercentage,
561    ) -> Result<Quantity, CooldownError> {
562        let base = self.cooldown(reference)?;
563        let value = base.value * percentage.value() / 100.0;
564        if !value.is_finite() {
565            return Err(CooldownError::CalculationOverflow {
566                ability: reference.clone(),
567            });
568        }
569        Quantity::new(value, Unit::from(units::SECONDS)).map_err(|_| {
570            CooldownError::CalculationOverflow {
571                ability: reference.clone(),
572            }
573        })
574    }
575
576    pub fn required_cooldown_percentage(
577        &self,
578        reference: &AbilityRef,
579        target: &Quantity,
580    ) -> Result<CooldownPercentage, CooldownError> {
581        let base = self.cooldown(reference)?;
582        if target.unit != Unit::from(units::SECONDS) {
583            return Err(CooldownError::TargetWrongUnit {
584                actual: target.unit.clone(),
585            });
586        }
587        if !target.value.is_finite() || target.value < 0.0 {
588            return Err(CooldownError::InvalidTarget {
589                value: target.value,
590            });
591        }
592        CooldownPercentage::new(target.value / base.value * 100.0)
593            .map_err(CooldownError::InvalidPercentage)
594    }
595
596    fn find_hero(&self, hero: &str) -> Result<&'a Hero, HeroId> {
597        let id = HeroId::new(hero);
598        self.catalog.hero(&id).ok_or(id)
599    }
600
601    fn ability_stat(
602        &self,
603        reference: &AbilityRef,
604        stat: StatKey,
605    ) -> Result<&'a Fact<StatValue>, GameplayQueryError> {
606        let ability = self.ability_ref(reference)?;
607        ability
608            .stat(&stat)
609            .ok_or_else(|| GameplayQueryError::MissingStat {
610                owner: StatOwner::Ability {
611                    reference: reference.clone(),
612                },
613                stat,
614            })
615    }
616
617    fn unique_slot(
618        &self,
619        hero: &'a Hero,
620        slot: LogicalSlot,
621    ) -> Result<&'a Ability, GameplayQueryError> {
622        match match_slot(hero, &slot) {
623            Ok(ability) => Ok(ability),
624            Err(SlotMiss::Missing) => Err(GameplayQueryError::MissingSlot {
625                hero: hero.id().clone(),
626                slot,
627            }),
628            Err(SlotMiss::Ambiguous(mut matches)) => {
629                matches.sort_by(|left, right| ability_order(left, right));
630                Err(GameplayQueryError::AmbiguousSlot {
631                    hero: hero.id().clone(),
632                    slot,
633                    candidates: matches
634                        .into_iter()
635                        .map(|ability| ability.reference(hero.id()))
636                        .collect(),
637                })
638            }
639        }
640    }
641
642    fn reference(
643        &self,
644        hero: impl AsRef<str>,
645        slot: impl AsRef<str>,
646        variant: Option<&AbilityVariant>,
647    ) -> Result<AbilityRef, GameplayQueryError> {
648        let hero = self.hero(hero)?;
649        let slot = LogicalSlot::new(slot.as_ref());
650        match variant {
651            Some(variant) => Ok(AbilityRef::new(
652                hero.id().clone(),
653                slot,
654                Some(variant.clone()),
655            )),
656            None => Ok(self.unique_slot(hero, slot)?.reference(hero.id())),
657        }
658    }
659
660    fn hero_for_names(
661        &self,
662        hero: impl AsRef<str>,
663    ) -> Result<&'a Hero, AbilityNameResolutionError> {
664        self.find_hero(hero.as_ref())
665            .map_err(|hero| AbilityNameResolutionError::MissingHero { hero })
666    }
667
668    fn reference_for_names(
669        &self,
670        hero: impl AsRef<str>,
671        slot: impl AsRef<str>,
672        variant: Option<&AbilityVariant>,
673    ) -> Result<AbilityRef, AbilityNameResolutionError> {
674        let hero = self.hero_for_names(hero)?;
675        let slot = LogicalSlot::new(slot.as_ref());
676        match variant {
677            Some(variant) => Ok(AbilityRef::new(
678                hero.id().clone(),
679                slot,
680                Some(variant.clone()),
681            )),
682            None => match match_slot(hero, &slot) {
683                Ok(ability) => Ok(ability.reference(hero.id())),
684                Err(SlotMiss::Missing) => Err(AbilityNameResolutionError::MissingSlot {
685                    hero: hero.id().clone(),
686                    slot,
687                }),
688                Err(SlotMiss::Ambiguous(matches)) => {
689                    Err(AbilityNameResolutionError::AmbiguousSlot {
690                        hero: hero.id().clone(),
691                        slot,
692                        candidates: matches
693                            .into_iter()
694                            .map(|ability| ability.reference(hero.id()))
695                            .collect(),
696                    })
697                }
698            },
699        }
700    }
701
702    fn cooldown_value(
703        &self,
704        reference: &AbilityRef,
705        ability: &'a Ability,
706    ) -> Result<&'a Quantity, CooldownError> {
707        let key = StatKey::from(crate::gameplay::stat_keys::COOLDOWN);
708        let Some(fact) = ability.stat(&key) else {
709            return Err(CooldownError::Missing {
710                ability: reference.clone(),
711            });
712        };
713        let StatValue::Quantity(quantity) = fact.value() else {
714            return Err(CooldownError::NonApplicable {
715                ability: reference.clone(),
716                reason: CooldownNonApplicability::WrongValueType,
717            });
718        };
719        let seconds = Unit::from(units::SECONDS);
720        if quantity.unit != seconds {
721            return Err(CooldownError::NonApplicable {
722                ability: reference.clone(),
723                reason: CooldownNonApplicability::WrongUnit {
724                    actual: quantity.unit.clone(),
725                },
726            });
727        }
728        if !quantity.value.is_finite() {
729            return Err(CooldownError::InvalidBase {
730                ability: reference.clone(),
731                value: quantity.value,
732            });
733        }
734        if quantity.value <= 0.0 {
735            return Err(CooldownError::NonApplicable {
736                ability: reference.clone(),
737                reason: CooldownNonApplicability::NonPositiveBase,
738            });
739        }
740        Ok(quantity)
741    }
742}
743
744fn ability_order(left: &Ability, right: &Ability) -> Ordering {
745    left.slot()
746        .cmp(right.slot())
747        .then_with(|| left.variant().cmp(&right.variant()))
748}