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