1pub mod data;
10pub mod query;
11
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13
14use serde::{Deserialize, Deserializer, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
19pub struct HeroId(String);
20
21impl HeroId {
22 pub fn new(value: impl Into<String>) -> Self {
23 Self(value.into())
24 }
25 pub const fn from_static(value: &'static str) -> HeroIdRef {
26 HeroIdRef(value)
27 }
28 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31}
32
33impl From<HeroIdRef> for HeroId {
34 fn from(value: HeroIdRef) -> Self {
35 Self::new(value.0)
36 }
37}
38
39impl From<&str> for HeroId {
40 fn from(value: &str) -> Self {
41 Self::new(value)
42 }
43}
44
45impl std::fmt::Display for HeroId {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub struct HeroIdRef(&'static str);
54
55impl HeroIdRef {
56 pub const fn new(value: &'static str) -> Self {
57 Self(value)
58 }
59 pub const fn as_str(self) -> &'static str {
60 self.0
61 }
62}
63
64impl AsRef<str> for HeroIdRef {
65 fn as_ref(&self) -> &str {
66 self.0
67 }
68}
69
70pub mod hero_ids {
73 use super::HeroIdRef;
74 pub const ANA: HeroIdRef = HeroIdRef::new("ana");
75 pub const ANRAN: HeroIdRef = HeroIdRef::new("anran");
76 pub const ASHE: HeroIdRef = HeroIdRef::new("ashe");
77 pub const BAPTISTE: HeroIdRef = HeroIdRef::new("baptiste");
78 pub const BASTION: HeroIdRef = HeroIdRef::new("bastion");
79 pub const BRIGITTE: HeroIdRef = HeroIdRef::new("brigitte");
80 pub const CASSIDY: HeroIdRef = HeroIdRef::new("cassidy");
81 pub const DMON: HeroIdRef = HeroIdRef::new("dmon");
82 pub const DOMINA: HeroIdRef = HeroIdRef::new("domina");
83 pub const DOOMFIST: HeroIdRef = HeroIdRef::new("doomfist");
84 pub const DVA: HeroIdRef = HeroIdRef::new("dva");
85 pub const ECHO: HeroIdRef = HeroIdRef::new("echo");
86 pub const EMRE: HeroIdRef = HeroIdRef::new("emre");
87 pub const FREJA: HeroIdRef = HeroIdRef::new("freja");
88 pub const GENJI: HeroIdRef = HeroIdRef::new("genji");
89 pub const ILLARI: HeroIdRef = HeroIdRef::new("illari");
90 pub const WRECKING_BALL: HeroIdRef = HeroIdRef::new("wreckingBall");
91 pub const HANZO: HeroIdRef = HeroIdRef::new("hanzo");
92 pub const JETPACK_CAT: HeroIdRef = HeroIdRef::new("jetpackCat");
93 pub const JUNKER_QUEEN: HeroIdRef = HeroIdRef::new("junkerQueen");
94 pub const JUNKRAT: HeroIdRef = HeroIdRef::new("junkrat");
95 pub const KIRIKO: HeroIdRef = HeroIdRef::new("kiriko");
96 pub const LUCIO: HeroIdRef = HeroIdRef::new("lucio");
97 pub const MAUGA: HeroIdRef = HeroIdRef::new("mauga");
98 pub const MEI: HeroIdRef = HeroIdRef::new("mei");
99 pub const MERCY: HeroIdRef = HeroIdRef::new("mercy");
100 pub const MIZUKI: HeroIdRef = HeroIdRef::new("mizuki");
101 pub const MOIRA: HeroIdRef = HeroIdRef::new("moira");
102 pub const ORISA: HeroIdRef = HeroIdRef::new("orisa");
103 pub const PHARAH: HeroIdRef = HeroIdRef::new("pharah");
104 pub const REAPER: HeroIdRef = HeroIdRef::new("reaper");
105 pub const REINHARDT: HeroIdRef = HeroIdRef::new("reinhardt");
106 pub const ROADHOG: HeroIdRef = HeroIdRef::new("roadhog");
107 pub const SHION: HeroIdRef = HeroIdRef::new("shion");
108 pub const SIERRA: HeroIdRef = HeroIdRef::new("sierra");
109 pub const SIGMA: HeroIdRef = HeroIdRef::new("sigma");
110 pub const SOJOURN: HeroIdRef = HeroIdRef::new("sojourn");
111 pub const SOLDIER: HeroIdRef = HeroIdRef::new("soldier");
112 pub const SOMBRA: HeroIdRef = HeroIdRef::new("sombra");
113 pub const SYMMETRA: HeroIdRef = HeroIdRef::new("symmetra");
114 pub const TORBJORN: HeroIdRef = HeroIdRef::new("torbjorn");
115 pub const TRACER: HeroIdRef = HeroIdRef::new("tracer");
116 pub const WIDOWMAKER: HeroIdRef = HeroIdRef::new("widowmaker");
117 pub const WINSTON: HeroIdRef = HeroIdRef::new("winston");
118 pub const ZARYA: HeroIdRef = HeroIdRef::new("zarya");
119 pub const ZENYATTA: HeroIdRef = HeroIdRef::new("zenyatta");
120 pub const RAMATTRA: HeroIdRef = HeroIdRef::new("ramattra");
121 pub const LIFEWEAVER: HeroIdRef = HeroIdRef::new("lifeweaver");
122 pub const VENTURE: HeroIdRef = HeroIdRef::new("venture");
123 pub const JUNO: HeroIdRef = HeroIdRef::new("juno");
124 pub const HAZARD: HeroIdRef = HeroIdRef::new("hazard");
125 pub const WUYANG: HeroIdRef = HeroIdRef::new("wuyang");
126 pub const VENDETTA: HeroIdRef = HeroIdRef::new("vendetta");
127}
128
129macro_rules! open_string_id {
130 ($name:ident, $reference:ident) => {
131 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
132 pub struct $name(String);
133 impl $name {
134 pub fn new(value: impl Into<String>) -> Self {
135 Self(value.into())
136 }
137 pub const fn from_static(value: &'static str) -> $reference {
138 $reference(value)
139 }
140 pub fn as_str(&self) -> &str {
141 &self.0
142 }
143 }
144 impl From<$reference> for $name {
145 fn from(value: $reference) -> Self {
146 Self::new(value.0)
147 }
148 }
149 impl From<&str> for $name {
150 fn from(value: &str) -> Self {
151 Self::new(value)
152 }
153 }
154 impl std::fmt::Display for $name {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.write_str(self.as_str())
157 }
158 }
159 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160 pub struct $reference(&'static str);
161 impl $reference {
162 pub const fn new(value: &'static str) -> Self {
163 Self(value)
164 }
165 pub const fn as_str(self) -> &'static str {
166 self.0
167 }
168 }
169 impl AsRef<str> for $reference {
170 fn as_ref(&self) -> &str {
171 self.0
172 }
173 }
174 };
175}
176
177open_string_id!(LogicalSlot, LogicalSlotRef);
178open_string_id!(AbilityVariant, AbilityVariantRef);
179open_string_id!(KeywordId, KeywordIdRef);
180open_string_id!(StatKey, StatKeyRef);
181open_string_id!(Unit, UnitRef);
182open_string_id!(HeroRole, HeroRoleRef);
183
184#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187#[serde(deny_unknown_fields)]
188pub struct AbilityRef {
189 hero: HeroId,
190 slot: LogicalSlot,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 variant: Option<AbilityVariant>,
193}
194
195impl AbilityRef {
196 pub fn new(hero: HeroId, slot: LogicalSlot, variant: Option<AbilityVariant>) -> Self {
197 Self {
198 hero,
199 slot,
200 variant,
201 }
202 }
203 pub fn hero(&self) -> &HeroId {
204 &self.hero
205 }
206 pub fn slot(&self) -> &LogicalSlot {
207 &self.slot
208 }
209 pub fn variant(&self) -> Option<&AbilityVariant> {
210 self.variant.as_ref()
211 }
212}
213
214pub mod slots {
216 use super::LogicalSlotRef;
217 pub const PRIMARY_FIRE: LogicalSlotRef = LogicalSlotRef::new("primaryFire");
218 pub const SECONDARY_FIRE: LogicalSlotRef = LogicalSlotRef::new("secondaryFire");
219 pub const ABILITY_1: LogicalSlotRef = LogicalSlotRef::new("ability1");
220 pub const ABILITY_2: LogicalSlotRef = LogicalSlotRef::new("ability2");
221 pub const ABILITY_3: LogicalSlotRef = LogicalSlotRef::new("ability3");
222 pub const ULTIMATE: LogicalSlotRef = LogicalSlotRef::new("ultimate");
223 pub const PASSIVE: LogicalSlotRef = LogicalSlotRef::new("passive");
224}
225
226pub mod stat_keys {
228 use super::StatKeyRef;
229 pub const COOLDOWN: StatKeyRef = StatKeyRef::new("cooldown");
230 pub const DAMAGE: StatKeyRef = StatKeyRef::new("damage");
231 pub const HEALING: StatKeyRef = StatKeyRef::new("healing");
232 pub const DURATION: StatKeyRef = StatKeyRef::new("duration");
233 pub const CHARGES: StatKeyRef = StatKeyRef::new("charges");
234 pub const RESOURCE_COST: StatKeyRef = StatKeyRef::new("resourceCost");
235}
236
237pub mod units {
239 use super::UnitRef;
240 pub const SECONDS: UnitRef = UnitRef::new("seconds");
241 pub const PERCENT: UnitRef = UnitRef::new("percent");
242 pub const HEALTH: UnitRef = UnitRef::new("health");
243 pub const DAMAGE: UnitRef = UnitRef::new("damage");
244 pub const HEALING: UnitRef = UnitRef::new("healing");
245 pub const METERS: UnitRef = UnitRef::new("meters");
246 pub const AMMO: UnitRef = UnitRef::new("ammo");
247 pub const CHARGES: UnitRef = UnitRef::new("charges");
248 pub const RESOURCE: UnitRef = UnitRef::new("resource");
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
253pub struct LocalizedText(BTreeMap<String, String>);
254
255impl LocalizedText {
256 pub fn new(values: impl IntoIterator<Item = (String, String)>) -> Self {
257 Self(values.into_iter().collect())
258 }
259 pub fn get(&self, locale: &str) -> Option<&str> {
260 self.0.get(locale).map(String::as_str).or_else(|| {
261 self.0
262 .iter()
263 .find(|(known, _)| known.eq_ignore_ascii_case(locale))
264 .map(|(_, text)| text.as_str())
265 })
266 }
267 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
268 self.0
269 .iter()
270 .map(|(locale, text)| (locale.as_str(), text.as_str()))
271 }
272 pub fn is_empty(&self) -> bool {
273 self.0.is_empty()
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280#[non_exhaustive]
281pub struct SourceReference {
282 pub source: String,
283 pub locator: String,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub note: Option<String>,
286}
287
288impl SourceReference {
289 pub fn new(source: impl Into<String>, locator: impl Into<String>) -> Self {
290 Self {
291 source: source.into(),
292 locator: locator.into(),
293 note: None,
294 }
295 }
296 pub fn with_note(mut self, note: impl Into<String>) -> Self {
297 self.note = Some(note.into());
298 self
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306#[non_exhaustive]
307pub struct GameplayDatasetIdentity {
308 pub dataset_id: String,
309 pub version: String,
310 pub digest: String,
311 pub source: String,
312 pub license: String,
313 pub target: String,
314 pub reviewed: bool,
315}
316
317impl GameplayDatasetIdentity {
318 pub fn new(
319 dataset_id: impl Into<String>,
320 version: impl Into<String>,
321 digest: impl Into<String>,
322 source: impl Into<String>,
323 license: impl Into<String>,
324 target: impl Into<String>,
325 reviewed: bool,
326 ) -> Self {
327 Self {
328 dataset_id: dataset_id.into(),
329 version: version.into(),
330 digest: digest.into(),
331 source: source.into(),
332 license: license.into(),
333 target: target.into(),
334 reviewed,
335 }
336 }
337}
338
339#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct Fact<T> {
342 pub value: T,
343 pub sources: Vec<SourceReference>,
344}
345
346impl<T> Fact<T> {
347 pub fn new(value: T, sources: Vec<SourceReference>) -> Self {
348 Self { value, sources }
349 }
350 pub fn value(&self) -> &T {
351 &self.value
352 }
353 pub fn sources(&self) -> &[SourceReference] {
354 &self.sources
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Serialize)]
360pub struct Quantity {
361 pub value: f64,
362 pub unit: Unit,
363}
364
365impl Quantity {
366 pub fn new(value: f64, unit: Unit) -> Result<Self, GameplayDataError> {
367 if !value.is_finite() {
368 return Err(GameplayDataError::InvalidQuantity { value });
369 }
370 Ok(Self { value, unit })
371 }
372}
373
374impl<'de> Deserialize<'de> for Quantity {
375 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
376 where
377 D: Deserializer<'de>,
378 {
379 #[derive(Deserialize)]
380 struct RawQuantity {
381 value: f64,
382 unit: Unit,
383 }
384 let raw = RawQuantity::deserialize(deserializer)?;
385 Self::new(raw.value, raw.unit).map_err(serde::de::Error::custom)
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
392pub enum StatValue {
393 Quantity(Quantity),
394 Text(String),
395 Boolean(bool),
396 Choice(String),
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct Ability {
403 slot: LogicalSlot,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 variant: Option<AbilityVariant>,
406 name: Fact<LocalizedText>,
407 #[serde(default)]
408 keywords: BTreeSet<KeywordId>,
409 #[serde(default)]
410 stats: BTreeMap<StatKey, Fact<StatValue>>,
411 sources: Vec<SourceReference>,
412}
413
414impl Ability {
415 pub fn new(
416 slot: LogicalSlot,
417 variant: Option<AbilityVariant>,
418 name: Fact<LocalizedText>,
419 sources: Vec<SourceReference>,
420 ) -> Self {
421 Self {
422 slot,
423 variant,
424 name,
425 keywords: BTreeSet::new(),
426 stats: BTreeMap::new(),
427 sources,
428 }
429 }
430 pub fn with_keyword(mut self, keyword: impl Into<KeywordId>) -> Self {
431 self.keywords.insert(keyword.into());
432 self
433 }
434 pub fn with_stat(mut self, key: StatKey, value: Fact<StatValue>) -> Self {
435 self.stats.insert(key, value);
436 self
437 }
438 pub fn reference(&self, hero: &HeroId) -> AbilityRef {
439 AbilityRef::new(hero.clone(), self.slot.clone(), self.variant.clone())
440 }
441 pub fn slot(&self) -> &LogicalSlot {
442 &self.slot
443 }
444 pub fn variant(&self) -> Option<&AbilityVariant> {
445 self.variant.as_ref()
446 }
447 pub fn name(&self) -> &Fact<LocalizedText> {
448 &self.name
449 }
450 pub fn keywords(&self) -> impl Iterator<Item = &KeywordId> {
451 self.keywords.iter()
452 }
453 pub fn has_keyword(&self, keyword: &str) -> bool {
454 self.keywords.iter().any(|known| known.as_str() == keyword)
455 }
456 pub fn stat(&self, key: &StatKey) -> Option<&Fact<StatValue>> {
457 self.stats.get(key)
458 }
459 pub fn stats(&self) -> impl Iterator<Item = (&StatKey, &Fact<StatValue>)> {
460 self.stats.iter()
461 }
462 pub fn sources(&self) -> &[SourceReference] {
463 &self.sources
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469#[serde(rename_all = "camelCase")]
470pub struct Hero {
471 id: HeroId,
472 name: Fact<LocalizedText>,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 role: Option<Fact<HeroRole>>,
475 #[serde(default)]
476 stats: BTreeMap<StatKey, Fact<StatValue>>,
477 abilities: Vec<Ability>,
478 sources: Vec<SourceReference>,
479}
480
481impl Hero {
482 pub fn new(
483 id: HeroId,
484 name: Fact<LocalizedText>,
485 abilities: Vec<Ability>,
486 sources: Vec<SourceReference>,
487 ) -> Self {
488 Self {
489 id,
490 name,
491 role: None,
492 stats: BTreeMap::new(),
493 abilities,
494 sources,
495 }
496 }
497 pub fn with_role(mut self, role: Fact<HeroRole>) -> Self {
498 self.role = Some(role);
499 self
500 }
501 pub fn with_stat(mut self, key: StatKey, value: Fact<StatValue>) -> Self {
502 self.stats.insert(key, value);
503 self
504 }
505 pub fn id(&self) -> &HeroId {
506 &self.id
507 }
508 pub fn name(&self) -> &Fact<LocalizedText> {
509 &self.name
510 }
511 pub fn role(&self) -> Option<&Fact<HeroRole>> {
512 self.role.as_ref()
513 }
514 pub fn stat(&self, key: &StatKey) -> Option<&Fact<StatValue>> {
515 self.stats.get(key)
516 }
517 pub fn stats(&self) -> impl Iterator<Item = (&StatKey, &Fact<StatValue>)> {
518 self.stats.iter()
519 }
520 pub fn abilities(&self) -> &[Ability] {
521 &self.abilities
522 }
523 pub fn abilities_in_slot(&self, slot: &LogicalSlot) -> Vec<&Ability> {
524 self.abilities
525 .iter()
526 .filter(|ability| ability.slot() == slot)
527 .collect()
528 }
529 pub fn ability(&self, slot: &LogicalSlot) -> Result<&Ability, AbilityLookupError> {
530 let matches = self.abilities_in_slot(slot);
531 match matches.as_slice() {
532 [] => Err(AbilityLookupError::Missing {
533 hero: self.id.clone(),
534 slot: slot.clone(),
535 }),
536 [ability] => Ok(ability),
537 _ => Err(AbilityLookupError::Ambiguous {
538 hero: self.id.clone(),
539 slot: slot.clone(),
540 candidates: matches
541 .into_iter()
542 .map(|ability| ability.reference(&self.id))
543 .collect(),
544 }),
545 }
546 }
547 pub fn ability_ref(
548 &self,
549 slot: &LogicalSlot,
550 variant: Option<&AbilityVariant>,
551 ) -> Result<&Ability, AbilityLookupError> {
552 match variant {
553 Some(variant) => self.ability_variant(slot, variant),
554 None => self.ability(slot),
555 }
556 }
557 pub fn ability_variant(
558 &self,
559 slot: &LogicalSlot,
560 variant: &AbilityVariant,
561 ) -> Result<&Ability, AbilityLookupError> {
562 self.abilities
563 .iter()
564 .find(|ability| ability.slot() == slot && ability.variant.as_ref() == Some(variant))
565 .ok_or_else(|| AbilityLookupError::MissingVariant {
566 hero: self.id.clone(),
567 slot: slot.clone(),
568 variant: variant.clone(),
569 })
570 }
571 pub fn sources(&self) -> &[SourceReference] {
572 &self.sources
573 }
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
578#[non_exhaustive]
579pub enum AbilityLookupError {
580 Missing {
581 hero: HeroId,
582 slot: LogicalSlot,
583 },
584 Ambiguous {
585 hero: HeroId,
586 slot: LogicalSlot,
587 candidates: Vec<AbilityRef>,
588 },
589 MissingVariant {
590 hero: HeroId,
591 slot: LogicalSlot,
592 variant: AbilityVariant,
593 },
594}
595
596impl std::fmt::Display for AbilityLookupError {
597 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598 match self {
599 Self::Missing { hero, slot } => {
600 write!(f, "hero '{hero}' has no ability in slot '{slot}'")
601 }
602 Self::Ambiguous {
603 hero,
604 slot,
605 candidates,
606 } => write!(
607 f,
608 "hero '{hero}' has multiple abilities in slot '{slot}': {candidates:?}"
609 ),
610 Self::MissingVariant {
611 hero,
612 slot,
613 variant,
614 } => write!(
615 f,
616 "hero '{hero}' has no ability in slot '{slot}' with variant '{variant}'"
617 ),
618 }
619 }
620}
621impl std::error::Error for AbilityLookupError {}
622
623#[derive(Debug, Clone, PartialEq)]
625#[non_exhaustive]
626pub enum GameplayDataError {
627 EmptyIdentity(&'static str),
628 DuplicateHero(HeroId),
629 DuplicateSlotVariant {
630 hero: HeroId,
631 slot: LogicalSlot,
632 variant: Option<AbilityVariant>,
633 },
634 VariantRequired {
635 hero: HeroId,
636 slot: LogicalSlot,
637 },
638 MissingSource(String),
639 EmptyId(&'static str),
640 InvalidQuantity {
641 value: f64,
642 },
643 Malformed(String),
644 UnsupportedSchema(u32),
645 DigestMismatch {
646 declared: String,
647 computed: String,
648 },
649}
650
651impl std::fmt::Display for GameplayDataError {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 match self {
654 Self::EmptyIdentity(field) => {
655 write!(f, "gameplay dataset identity field '{field}' is empty")
656 }
657 Self::DuplicateHero(id) => write!(f, "duplicate hero identity '{id}'"),
658 Self::DuplicateSlotVariant {
659 hero,
660 slot,
661 variant,
662 } => write!(
663 f,
664 "hero '{hero}' has duplicate slot/variant '{slot}'/'{variant:?}'"
665 ),
666 Self::VariantRequired { hero, slot } => write!(
667 f,
668 "hero '{hero}' has multiple abilities in slot '{slot}' but not every record has a variant"
669 ),
670 Self::MissingSource(path) => {
671 write!(f, "gameplay fact '{path}' has no source reference")
672 }
673 Self::EmptyId(field) => write!(f, "gameplay identity '{field}' is empty"),
674 Self::InvalidQuantity { value } => write!(f, "quantity value '{value}' is not finite"),
675 Self::Malformed(message) => write!(f, "malformed gameplay data: {message}"),
676 Self::UnsupportedSchema(version) => {
677 write!(f, "unsupported gameplay data schemaVersion {version}")
678 }
679 Self::DigestMismatch { declared, computed } => write!(
680 f,
681 "gameplay data digest mismatch: declared '{declared}', content '{computed}'"
682 ),
683 }
684 }
685}
686impl std::error::Error for GameplayDataError {}
687
688#[derive(Debug, Clone)]
690pub struct GameplayCatalog {
691 identity: GameplayDatasetIdentity,
692 heroes: Vec<Hero>,
693 by_id: HashMap<HeroId, usize>,
694}
695
696impl GameplayCatalog {
697 pub fn new(
698 identity: GameplayDatasetIdentity,
699 mut heroes: Vec<Hero>,
700 ) -> Result<Self, GameplayDataError> {
701 for (field, value) in [
702 ("datasetId", identity.dataset_id.as_str()),
703 ("version", identity.version.as_str()),
704 ("digest", identity.digest.as_str()),
705 ("source", identity.source.as_str()),
706 ("license", identity.license.as_str()),
707 ("target", identity.target.as_str()),
708 ] {
709 if value.is_empty() {
710 return Err(GameplayDataError::EmptyIdentity(field));
711 }
712 }
713 heroes.sort_by(|left, right| left.id.cmp(&right.id));
714 for hero in &mut heroes {
715 hero.abilities.sort_by(|left, right| {
716 (&left.slot, &left.variant).cmp(&(&right.slot, &right.variant))
717 });
718 }
719 let mut by_id = HashMap::with_capacity(heroes.len());
720 for (index, hero) in heroes.iter().enumerate() {
721 if by_id.insert(hero.id.clone(), index).is_some() {
722 return Err(GameplayDataError::DuplicateHero(hero.id.clone()));
723 }
724 validate_hero(hero)?;
725 }
726 Ok(Self {
727 identity,
728 heroes,
729 by_id,
730 })
731 }
732 pub fn identity(&self) -> &GameplayDatasetIdentity {
733 &self.identity
734 }
735 pub fn heroes(&self) -> &[Hero] {
736 &self.heroes
737 }
738 pub fn hero(&self, id: &HeroId) -> Option<&Hero> {
739 self.by_id.get(id).map(|index| &self.heroes[*index])
740 }
741 pub fn hero_by_id(&self, id: impl AsRef<str>) -> Option<&Hero> {
742 self.hero(&HeroId::new(id.as_ref()))
743 }
744 pub fn ability(&self, reference: &AbilityRef) -> Result<&Ability, AbilityLookupError> {
745 self.hero(reference.hero())
746 .ok_or_else(|| AbilityLookupError::Missing {
747 hero: reference.hero().clone(),
748 slot: reference.slot().clone(),
749 })?
750 .ability_ref(reference.slot(), reference.variant())
751 }
752 pub fn find_abilities_by_keyword(&self, keyword: &str) -> Vec<(&Hero, &Ability)> {
753 self.heroes
754 .iter()
755 .flat_map(|hero| {
756 hero.abilities()
757 .iter()
758 .filter(move |ability| ability.has_keyword(keyword))
759 .map(move |ability| (hero, ability))
760 })
761 .collect()
762 }
763}
764
765fn validate_hero(hero: &Hero) -> Result<(), GameplayDataError> {
766 if hero.id.as_str().is_empty() {
767 return Err(GameplayDataError::EmptyId("hero"));
768 }
769 if hero.sources.is_empty() {
770 return Err(GameplayDataError::MissingSource(format!(
771 "hero {}",
772 hero.id
773 )));
774 }
775 if hero.name.sources.is_empty() {
776 return Err(GameplayDataError::MissingSource(format!(
777 "hero {} name",
778 hero.id
779 )));
780 }
781 validate_sources(&format!("hero {}", hero.id), &hero.sources)?;
782 validate_sources(&format!("hero {} name", hero.id), &hero.name.sources)?;
783 if let Some(role) = &hero.role {
784 if role.value.as_str().is_empty() {
785 return Err(GameplayDataError::EmptyId("hero role"));
786 }
787 validate_fact(&format!("hero {} role", hero.id), role)?;
788 }
789 for (key, fact) in &hero.stats {
790 if key.as_str().is_empty() {
791 return Err(GameplayDataError::EmptyId("hero stat"));
792 }
793 validate_fact(&format!("hero {} stat {}", hero.id, key), fact)?;
794 validate_stat_value(&format!("hero {} stat {}", hero.id, key), &fact.value)?;
795 }
796 let mut slot_variants = BTreeSet::new();
797 let mut slot_counts: BTreeMap<LogicalSlot, usize> = BTreeMap::new();
798 for ability in &hero.abilities {
799 if ability.slot.as_str().trim().is_empty() {
800 return Err(GameplayDataError::EmptyId("ability slot"));
801 }
802 if ability
803 .variant
804 .as_ref()
805 .is_some_and(|variant| variant.as_str().is_empty())
806 {
807 return Err(GameplayDataError::EmptyId("ability variant"));
808 }
809 if ability.sources.is_empty() {
810 return Err(GameplayDataError::MissingSource(format!(
811 "hero {} ability {}",
812 hero.id, ability.slot
813 )));
814 }
815 validate_sources(
816 &format!("hero {} ability {}", hero.id, ability.slot),
817 &ability.sources,
818 )?;
819 if ability.name.sources.is_empty() {
820 return Err(GameplayDataError::MissingSource(format!(
821 "hero {} ability {} name",
822 hero.id, ability.slot
823 )));
824 }
825 validate_sources(
826 &format!("hero {} ability {} name", hero.id, ability.slot),
827 &ability.name.sources,
828 )?;
829 let slot_variant = (ability.slot.clone(), ability.variant.clone());
830 if !slot_variants.insert(slot_variant) {
831 return Err(GameplayDataError::DuplicateSlotVariant {
832 hero: hero.id.clone(),
833 slot: ability.slot.clone(),
834 variant: ability.variant.clone(),
835 });
836 }
837 *slot_counts.entry(ability.slot.clone()).or_default() += 1;
838 for (key, fact) in &ability.stats {
839 if key.as_str().is_empty() {
840 return Err(GameplayDataError::EmptyId("ability stat"));
841 }
842 validate_fact(
843 &format!("hero {} ability {} stat {}", hero.id, ability.slot, key),
844 fact,
845 )?;
846 validate_stat_value(
847 &format!("hero {} ability {} stat {}", hero.id, ability.slot, key),
848 &fact.value,
849 )?;
850 }
851 }
852 for (slot, count) in slot_counts {
853 if count > 1
854 && hero
855 .abilities
856 .iter()
857 .filter(|ability| ability.slot == slot)
858 .any(|ability| ability.variant.is_none())
859 {
860 return Err(GameplayDataError::VariantRequired {
861 hero: hero.id.clone(),
862 slot,
863 });
864 }
865 }
866 Ok(())
867}
868
869fn validate_stat_value(_path: &str, value: &StatValue) -> Result<(), GameplayDataError> {
870 if let StatValue::Quantity(quantity) = value {
871 if !quantity.value.is_finite() {
872 return Err(GameplayDataError::InvalidQuantity {
873 value: quantity.value,
874 });
875 }
876 if quantity.unit.as_str().is_empty() {
877 return Err(GameplayDataError::EmptyId("quantity unit"));
878 }
879 }
880 Ok(())
881}
882
883fn validate_fact<T>(path: &str, fact: &Fact<T>) -> Result<(), GameplayDataError> {
884 if fact.sources.is_empty() {
885 return Err(GameplayDataError::MissingSource(path.to_string()));
886 }
887 validate_sources(path, &fact.sources)
888}
889
890fn validate_sources(path: &str, sources: &[SourceReference]) -> Result<(), GameplayDataError> {
891 for item in sources {
892 if item.source.is_empty() || item.locator.is_empty() {
893 return Err(GameplayDataError::MissingSource(path.to_string()));
894 }
895 }
896 Ok(())
897}