1use 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#[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}