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
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}