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)]
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#[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}