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