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
293#[derive(Debug, Clone, Copy)]
294pub struct GameplayQuery<'a> {
295    catalog: &'a GameplayCatalog,
296}
297impl GameplayCatalog {
298    pub fn query(&self) -> GameplayQuery<'_> {
299        GameplayQuery { catalog: self }
300    }
301}
302
303impl<'a> GameplayQuery<'a> {
304    pub fn heroes(&self) -> &'a [Hero] {
305        self.catalog.heroes()
306    }
307
308    pub fn hero(&self, hero: impl AsRef<str>) -> Result<&'a Hero, GameplayQueryError> {
309        let id = HeroId::new(hero.as_ref());
310        self.catalog
311            .hero(&id)
312            .ok_or(GameplayQueryError::MissingHero { hero: id })
313    }
314
315    pub fn kit(&self, hero: impl AsRef<str>) -> Result<Vec<&'a Ability>, GameplayQueryError> {
316        let hero = self.hero(hero)?;
317        let mut abilities = hero.abilities().iter().collect::<Vec<_>>();
318        abilities.sort_by(|left, right| ability_order(left, right));
319        Ok(abilities)
320    }
321
322    pub fn slot(
323        &self,
324        hero: impl AsRef<str>,
325        slot: impl AsRef<str>,
326    ) -> Result<Vec<&'a Ability>, GameplayQueryError> {
327        let hero = self.hero(hero)?;
328        let slot = LogicalSlot::new(slot.as_ref());
329        let mut abilities = hero.abilities_in_slot(&slot);
330        if abilities.is_empty() {
331            return Err(GameplayQueryError::MissingSlot {
332                hero: hero.id().clone(),
333                slot,
334            });
335        }
336        abilities.sort_by(|left, right| ability_order(left, right));
337        Ok(abilities)
338    }
339
340    pub fn ability(
341        &self,
342        hero: impl AsRef<str>,
343        slot: impl AsRef<str>,
344    ) -> Result<&'a Ability, GameplayQueryError> {
345        let hero = self.hero(hero)?;
346        self.unique_slot(hero, LogicalSlot::new(slot.as_ref()))
347    }
348
349    pub fn ability_ref(&self, reference: &AbilityRef) -> Result<&'a Ability, GameplayQueryError> {
350        self.catalog
351            .ability(reference)
352            .map_err(|error| match error {
353                crate::gameplay::AbilityLookupError::Missing { .. }
354                | crate::gameplay::AbilityLookupError::MissingVariant { .. } => {
355                    GameplayQueryError::MissingAbility {
356                        reference: reference.clone(),
357                    }
358                }
359                crate::gameplay::AbilityLookupError::Ambiguous {
360                    hero,
361                    slot,
362                    candidates,
363                } => GameplayQueryError::AmbiguousSlot {
364                    hero,
365                    slot,
366                    candidates,
367                },
368            })
369    }
370
371    pub fn slot_ability(
372        &self,
373        hero: impl AsRef<str>,
374        slot: impl AsRef<str>,
375    ) -> Result<&'a Ability, GameplayQueryError> {
376        self.ability(hero, slot)
377    }
378
379    pub fn variant(
380        &self,
381        hero: impl AsRef<str>,
382        slot: impl AsRef<str>,
383        variant: impl AsRef<str>,
384    ) -> Result<&'a Ability, GameplayQueryError> {
385        let hero = self.hero(hero)?;
386        let slot = LogicalSlot::new(slot.as_ref());
387        let variant = AbilityVariant::new(variant.as_ref());
388        hero.ability_variant(&slot, &variant)
389            .map_err(|_| GameplayQueryError::MissingVariant {
390                hero: hero.id().clone(),
391                slot,
392                variant,
393            })
394    }
395
396    pub fn keyword(&self, keyword: impl AsRef<str>) -> Vec<AbilityMatch<'a>> {
397        let mut matches = self
398            .catalog
399            .heroes()
400            .iter()
401            .flat_map(|hero| {
402                hero.abilities()
403                    .iter()
404                    .filter(|ability| ability.has_keyword(keyword.as_ref()))
405                    .map(move |ability| AbilityMatch { hero, ability })
406            })
407            .collect::<Vec<_>>();
408        matches.sort_by(|left, right| {
409            left.hero
410                .id()
411                .cmp(right.hero.id())
412                .then_with(|| ability_order(left.ability, right.ability))
413        });
414        matches
415    }
416
417    pub fn hero_stat(
418        &self,
419        hero: impl AsRef<str>,
420        stat: impl AsRef<str>,
421    ) -> Result<&'a Fact<StatValue>, GameplayQueryError> {
422        let hero = self.hero(hero)?;
423        let stat = StatKey::new(stat.as_ref());
424        hero.stat(&stat)
425            .ok_or_else(|| GameplayQueryError::MissingStat {
426                owner: StatOwner::Hero(hero.id().clone()),
427                stat,
428            })
429    }
430
431    pub fn stat(
432        &self,
433        hero: impl AsRef<str>,
434        slot: impl AsRef<str>,
435        variant: Option<&AbilityVariant>,
436        stat: impl AsRef<str>,
437    ) -> Result<&'a Fact<StatValue>, GameplayQueryError> {
438        let reference = self.reference(hero, slot, variant)?;
439        let ability = self.ability_ref(&reference)?;
440        let stat = StatKey::new(stat.as_ref());
441        ability.stat(&stat).ok_or(GameplayQueryError::MissingStat {
442            owner: StatOwner::Ability { reference },
443            stat,
444        })
445    }
446
447    pub fn quantity_stat(
448        &self,
449        hero: impl AsRef<str>,
450        slot: impl AsRef<str>,
451        variant: Option<&AbilityVariant>,
452        stat: impl AsRef<str>,
453    ) -> Result<&'a Quantity, GameplayQueryError> {
454        let reference = self.reference(hero, slot, variant)?;
455        let stat_key = StatKey::new(stat.as_ref());
456        let fact = self.stat(
457            reference.hero().as_str(),
458            reference.slot().as_str(),
459            reference.variant(),
460            stat_key.as_str(),
461        )?;
462        match fact.value() {
463            StatValue::Quantity(quantity) => Ok(quantity),
464            _ => Err(GameplayQueryError::WrongStatType {
465                owner: StatOwner::Ability { reference },
466                stat: stat_key,
467                expected: "quantity",
468            }),
469        }
470    }
471
472    pub fn ability_name(
473        &self,
474        hero: impl AsRef<str>,
475        slot: impl AsRef<str>,
476        variant: Option<&AbilityVariant>,
477        locale: impl AsRef<str>,
478    ) -> Result<&'a str, AbilityNameResolutionError> {
479        let reference = self.reference_for_names(hero, slot, variant)?;
480        let locale = locale.as_ref();
481        let ability = self.catalog.ability(&reference).map_err(|_| {
482            AbilityNameResolutionError::MissingVariant {
483                reference: reference.clone(),
484            }
485        })?;
486        if let Some(name) = ability.name().value().get(locale) {
487            return Ok(name);
488        }
489        if self
490            .catalog
491            .heroes()
492            .iter()
493            .flat_map(|hero| hero.abilities())
494            .any(|ability| ability.name().value().get(locale).is_some())
495        {
496            return Err(AbilityNameResolutionError::MissingName {
497                reference,
498                locale: locale.to_string(),
499            });
500        }
501        Err(AbilityNameResolutionError::UnsupportedLocale {
502            locale: locale.to_string(),
503        })
504    }
505
506    pub fn resolve_ability_name(
507        &self,
508        hero: impl AsRef<str>,
509        locale: impl AsRef<str>,
510        name: impl AsRef<str>,
511    ) -> Result<AbilityRef, AbilityNameResolutionError> {
512        let hero = self.hero_for_names(hero)?;
513        let locale = locale.as_ref();
514        let name = name.as_ref();
515        let supported = hero
516            .abilities()
517            .iter()
518            .any(|ability| ability.name().value().get(locale).is_some());
519        if !supported {
520            return Err(AbilityNameResolutionError::UnsupportedLocale {
521                locale: locale.to_string(),
522            });
523        }
524        let matches = hero
525            .abilities()
526            .iter()
527            .filter(|ability| ability.name().value().get(locale) == Some(name))
528            .map(|ability| ability.reference(hero.id()))
529            .collect::<Vec<_>>();
530        match matches.as_slice() {
531            [] => Err(AbilityNameResolutionError::MissingDisplayName {
532                hero: hero.id().clone(),
533                locale: locale.to_string(),
534                name: name.to_string(),
535            }),
536            [reference] => Ok(reference.clone()),
537            _ => Err(AbilityNameResolutionError::AmbiguousName {
538                hero: hero.id().clone(),
539                locale: locale.to_string(),
540                name: name.to_string(),
541                candidates: matches,
542            }),
543        }
544    }
545
546    pub fn cooldown(&self, reference: &AbilityRef) -> Result<&'a Quantity, CooldownError> {
547        let ability = self
548            .ability_ref(reference)
549            .map_err(|_| CooldownError::MissingAbility {
550                reference: reference.clone(),
551            })?;
552        self.cooldown_value(reference, ability)
553    }
554
555    pub fn effective_cooldown(
556        &self,
557        reference: &AbilityRef,
558        percentage: CooldownPercentage,
559    ) -> Result<Quantity, CooldownError> {
560        let base = self.cooldown(reference)?;
561        let value = base.value * percentage.value() / 100.0;
562        if !value.is_finite() {
563            return Err(CooldownError::CalculationOverflow {
564                ability: reference.clone(),
565            });
566        }
567        Quantity::new(value, Unit::from(units::SECONDS)).map_err(|_| {
568            CooldownError::CalculationOverflow {
569                ability: reference.clone(),
570            }
571        })
572    }
573
574    pub fn required_cooldown_percentage(
575        &self,
576        reference: &AbilityRef,
577        target: &Quantity,
578    ) -> Result<CooldownPercentage, CooldownError> {
579        let base = self.cooldown(reference)?;
580        if target.unit != Unit::from(units::SECONDS) {
581            return Err(CooldownError::TargetWrongUnit {
582                actual: target.unit.clone(),
583            });
584        }
585        if !target.value.is_finite() || target.value < 0.0 {
586            return Err(CooldownError::InvalidTarget {
587                value: target.value,
588            });
589        }
590        CooldownPercentage::new(target.value / base.value * 100.0)
591            .map_err(CooldownError::InvalidPercentage)
592    }
593
594    fn unique_slot(
595        &self,
596        hero: &'a Hero,
597        slot: LogicalSlot,
598    ) -> Result<&'a Ability, GameplayQueryError> {
599        let mut matches = hero.abilities_in_slot(&slot);
600        match matches.len() {
601            0 => Err(GameplayQueryError::MissingSlot {
602                hero: hero.id().clone(),
603                slot,
604            }),
605            1 => Ok(matches.pop().expect("length checked")),
606            _ => {
607                matches.sort_by(|left, right| ability_order(left, right));
608                Err(GameplayQueryError::AmbiguousSlot {
609                    hero: hero.id().clone(),
610                    slot,
611                    candidates: matches
612                        .into_iter()
613                        .map(|ability| ability.reference(hero.id()))
614                        .collect(),
615                })
616            }
617        }
618    }
619
620    fn reference(
621        &self,
622        hero: impl AsRef<str>,
623        slot: impl AsRef<str>,
624        variant: Option<&AbilityVariant>,
625    ) -> Result<AbilityRef, GameplayQueryError> {
626        let hero = self.hero(hero)?;
627        let slot = LogicalSlot::new(slot.as_ref());
628        match variant {
629            Some(variant) => Ok(AbilityRef::new(
630                hero.id().clone(),
631                slot,
632                Some(variant.clone()),
633            )),
634            None => Ok(self.unique_slot(hero, slot)?.reference(hero.id())),
635        }
636    }
637
638    fn hero_for_names(
639        &self,
640        hero: impl AsRef<str>,
641    ) -> Result<&'a Hero, AbilityNameResolutionError> {
642        let id = HeroId::new(hero.as_ref());
643        self.catalog
644            .hero(&id)
645            .ok_or(AbilityNameResolutionError::MissingHero { hero: id })
646    }
647
648    fn reference_for_names(
649        &self,
650        hero: impl AsRef<str>,
651        slot: impl AsRef<str>,
652        variant: Option<&AbilityVariant>,
653    ) -> Result<AbilityRef, AbilityNameResolutionError> {
654        let hero = self.hero_for_names(hero)?;
655        let slot = LogicalSlot::new(slot.as_ref());
656        match variant {
657            Some(variant) => Ok(AbilityRef::new(
658                hero.id().clone(),
659                slot,
660                Some(variant.clone()),
661            )),
662            None => {
663                let mut matches = hero.abilities_in_slot(&slot);
664                match matches.len() {
665                    0 => Err(AbilityNameResolutionError::MissingSlot {
666                        hero: hero.id().clone(),
667                        slot,
668                    }),
669                    1 => Ok(matches.pop().expect("length checked").reference(hero.id())),
670                    _ => Err(AbilityNameResolutionError::AmbiguousSlot {
671                        hero: hero.id().clone(),
672                        slot,
673                        candidates: matches
674                            .into_iter()
675                            .map(|ability| ability.reference(hero.id()))
676                            .collect(),
677                    }),
678                }
679            }
680        }
681    }
682
683    fn cooldown_value(
684        &self,
685        reference: &AbilityRef,
686        ability: &'a Ability,
687    ) -> Result<&'a Quantity, CooldownError> {
688        let key = StatKey::from(crate::gameplay::stat_keys::COOLDOWN);
689        let Some(fact) = ability.stat(&key) else {
690            return Err(CooldownError::Missing {
691                ability: reference.clone(),
692            });
693        };
694        let StatValue::Quantity(quantity) = fact.value() else {
695            return Err(CooldownError::NonApplicable {
696                ability: reference.clone(),
697                reason: CooldownNonApplicability::WrongValueType,
698            });
699        };
700        let seconds = Unit::from(units::SECONDS);
701        if quantity.unit != seconds {
702            return Err(CooldownError::NonApplicable {
703                ability: reference.clone(),
704                reason: CooldownNonApplicability::WrongUnit {
705                    actual: quantity.unit.clone(),
706                },
707            });
708        }
709        if !quantity.value.is_finite() {
710            return Err(CooldownError::InvalidBase {
711                ability: reference.clone(),
712                value: quantity.value,
713            });
714        }
715        if quantity.value <= 0.0 {
716            return Err(CooldownError::NonApplicable {
717                ability: reference.clone(),
718                reason: CooldownNonApplicability::NonPositiveBase,
719            });
720        }
721        Ok(quantity)
722    }
723}
724
725fn ability_order(left: &Ability, right: &Ability) -> Ordering {
726    left.slot()
727        .cmp(right.slot())
728        .then_with(|| left.variant().cmp(&right.variant()))
729}