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")]
280pub struct SourceReference {
281 pub source: String,
282 pub locator: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub note: Option<String>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct GameplayDatasetIdentity {
292 pub dataset_id: String,
293 pub version: String,
294 pub digest: String,
295 pub source: String,
296 pub license: String,
297 pub target: String,
298 pub reviewed: bool,
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct Fact<T> {
304 pub value: T,
305 pub sources: Vec<SourceReference>,
306}
307
308impl<T> Fact<T> {
309 pub fn new(value: T, sources: Vec<SourceReference>) -> Self {
310 Self { value, sources }
311 }
312 pub fn value(&self) -> &T {
313 &self.value
314 }
315 pub fn sources(&self) -> &[SourceReference] {
316 &self.sources
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Serialize)]
322pub struct Quantity {
323 pub value: f64,
324 pub unit: Unit,
325}
326
327impl Quantity {
328 pub fn new(value: f64, unit: Unit) -> Result<Self, GameplayDataError> {
329 if !value.is_finite() {
330 return Err(GameplayDataError::InvalidQuantity { value });
331 }
332 Ok(Self { value, unit })
333 }
334}
335
336impl<'de> Deserialize<'de> for Quantity {
337 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
338 where
339 D: Deserializer<'de>,
340 {
341 #[derive(Deserialize)]
342 struct RawQuantity {
343 value: f64,
344 unit: Unit,
345 }
346 let raw = RawQuantity::deserialize(deserializer)?;
347 Self::new(raw.value, raw.unit).map_err(serde::de::Error::custom)
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
354pub enum StatValue {
355 Quantity(Quantity),
356 Text(String),
357 Boolean(bool),
358 Choice(String),
359}
360
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct Ability {
365 slot: LogicalSlot,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 variant: Option<AbilityVariant>,
368 name: Fact<LocalizedText>,
369 #[serde(default)]
370 keywords: BTreeSet<KeywordId>,
371 #[serde(default)]
372 stats: BTreeMap<StatKey, Fact<StatValue>>,
373 sources: Vec<SourceReference>,
374}
375
376impl Ability {
377 pub fn new(
378 slot: LogicalSlot,
379 variant: Option<AbilityVariant>,
380 name: Fact<LocalizedText>,
381 sources: Vec<SourceReference>,
382 ) -> Self {
383 Self {
384 slot,
385 variant,
386 name,
387 keywords: BTreeSet::new(),
388 stats: BTreeMap::new(),
389 sources,
390 }
391 }
392 pub fn with_keyword(mut self, keyword: impl Into<KeywordId>) -> Self {
393 self.keywords.insert(keyword.into());
394 self
395 }
396 pub fn with_stat(mut self, key: StatKey, value: Fact<StatValue>) -> Self {
397 self.stats.insert(key, value);
398 self
399 }
400 pub fn reference(&self, hero: &HeroId) -> AbilityRef {
401 AbilityRef::new(hero.clone(), self.slot.clone(), self.variant.clone())
402 }
403 pub fn slot(&self) -> &LogicalSlot {
404 &self.slot
405 }
406 pub fn variant(&self) -> Option<&AbilityVariant> {
407 self.variant.as_ref()
408 }
409 pub fn name(&self) -> &Fact<LocalizedText> {
410 &self.name
411 }
412 pub fn keywords(&self) -> impl Iterator<Item = &KeywordId> {
413 self.keywords.iter()
414 }
415 pub fn has_keyword(&self, keyword: &str) -> bool {
416 self.keywords.iter().any(|known| known.as_str() == keyword)
417 }
418 pub fn stat(&self, key: &StatKey) -> Option<&Fact<StatValue>> {
419 self.stats.get(key)
420 }
421 pub fn stats(&self) -> impl Iterator<Item = (&StatKey, &Fact<StatValue>)> {
422 self.stats.iter()
423 }
424 pub fn sources(&self) -> &[SourceReference] {
425 &self.sources
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct Hero {
433 id: HeroId,
434 name: Fact<LocalizedText>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 role: Option<Fact<HeroRole>>,
437 #[serde(default)]
438 stats: BTreeMap<StatKey, Fact<StatValue>>,
439 abilities: Vec<Ability>,
440 sources: Vec<SourceReference>,
441}
442
443impl Hero {
444 pub fn new(
445 id: HeroId,
446 name: Fact<LocalizedText>,
447 abilities: Vec<Ability>,
448 sources: Vec<SourceReference>,
449 ) -> Self {
450 Self {
451 id,
452 name,
453 role: None,
454 stats: BTreeMap::new(),
455 abilities,
456 sources,
457 }
458 }
459 pub fn with_role(mut self, role: Fact<HeroRole>) -> Self {
460 self.role = Some(role);
461 self
462 }
463 pub fn with_stat(mut self, key: StatKey, value: Fact<StatValue>) -> Self {
464 self.stats.insert(key, value);
465 self
466 }
467 pub fn id(&self) -> &HeroId {
468 &self.id
469 }
470 pub fn name(&self) -> &Fact<LocalizedText> {
471 &self.name
472 }
473 pub fn role(&self) -> Option<&Fact<HeroRole>> {
474 self.role.as_ref()
475 }
476 pub fn stat(&self, key: &StatKey) -> Option<&Fact<StatValue>> {
477 self.stats.get(key)
478 }
479 pub fn stats(&self) -> impl Iterator<Item = (&StatKey, &Fact<StatValue>)> {
480 self.stats.iter()
481 }
482 pub fn abilities(&self) -> &[Ability] {
483 &self.abilities
484 }
485 pub fn abilities_in_slot(&self, slot: &LogicalSlot) -> Vec<&Ability> {
486 self.abilities
487 .iter()
488 .filter(|ability| ability.slot() == slot)
489 .collect()
490 }
491 pub fn ability(&self, slot: &LogicalSlot) -> Result<&Ability, AbilityLookupError> {
492 let matches = self.abilities_in_slot(slot);
493 match matches.as_slice() {
494 [] => Err(AbilityLookupError::Missing {
495 hero: self.id.clone(),
496 slot: slot.clone(),
497 }),
498 [ability] => Ok(ability),
499 _ => Err(AbilityLookupError::Ambiguous {
500 hero: self.id.clone(),
501 slot: slot.clone(),
502 candidates: matches
503 .into_iter()
504 .map(|ability| ability.reference(&self.id))
505 .collect(),
506 }),
507 }
508 }
509 pub fn ability_ref(
510 &self,
511 slot: &LogicalSlot,
512 variant: Option<&AbilityVariant>,
513 ) -> Result<&Ability, AbilityLookupError> {
514 match variant {
515 Some(variant) => self.ability_variant(slot, variant),
516 None => self.ability(slot),
517 }
518 }
519 pub fn ability_variant(
520 &self,
521 slot: &LogicalSlot,
522 variant: &AbilityVariant,
523 ) -> Result<&Ability, AbilityLookupError> {
524 self.abilities
525 .iter()
526 .find(|ability| ability.slot() == slot && ability.variant.as_ref() == Some(variant))
527 .ok_or_else(|| AbilityLookupError::MissingVariant {
528 hero: self.id.clone(),
529 slot: slot.clone(),
530 variant: variant.clone(),
531 })
532 }
533 pub fn sources(&self) -> &[SourceReference] {
534 &self.sources
535 }
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
540pub enum AbilityLookupError {
541 Missing {
542 hero: HeroId,
543 slot: LogicalSlot,
544 },
545 Ambiguous {
546 hero: HeroId,
547 slot: LogicalSlot,
548 candidates: Vec<AbilityRef>,
549 },
550 MissingVariant {
551 hero: HeroId,
552 slot: LogicalSlot,
553 variant: AbilityVariant,
554 },
555}
556
557impl std::fmt::Display for AbilityLookupError {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 match self {
560 Self::Missing { hero, slot } => {
561 write!(f, "hero '{hero}' has no ability in slot '{slot}'")
562 }
563 Self::Ambiguous {
564 hero,
565 slot,
566 candidates,
567 } => write!(
568 f,
569 "hero '{hero}' has multiple abilities in slot '{slot}': {candidates:?}"
570 ),
571 Self::MissingVariant {
572 hero,
573 slot,
574 variant,
575 } => write!(
576 f,
577 "hero '{hero}' has no ability in slot '{slot}' with variant '{variant}'"
578 ),
579 }
580 }
581}
582impl std::error::Error for AbilityLookupError {}
583
584#[derive(Debug, Clone, PartialEq)]
586pub enum GameplayDataError {
587 EmptyIdentity(&'static str),
588 DuplicateHero(HeroId),
589 DuplicateSlotVariant {
590 hero: HeroId,
591 slot: LogicalSlot,
592 variant: Option<AbilityVariant>,
593 },
594 VariantRequired {
595 hero: HeroId,
596 slot: LogicalSlot,
597 },
598 MissingSource(String),
599 EmptyId(&'static str),
600 InvalidQuantity {
601 value: f64,
602 },
603 Malformed(String),
604 UnsupportedSchema(u32),
605 DigestMismatch {
606 declared: String,
607 computed: String,
608 },
609}
610
611impl std::fmt::Display for GameplayDataError {
612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 match self {
614 Self::EmptyIdentity(field) => {
615 write!(f, "gameplay dataset identity field '{field}' is empty")
616 }
617 Self::DuplicateHero(id) => write!(f, "duplicate hero identity '{id}'"),
618 Self::DuplicateSlotVariant {
619 hero,
620 slot,
621 variant,
622 } => write!(
623 f,
624 "hero '{hero}' has duplicate slot/variant '{slot}'/'{variant:?}'"
625 ),
626 Self::VariantRequired { hero, slot } => write!(
627 f,
628 "hero '{hero}' has multiple abilities in slot '{slot}' but not every record has a variant"
629 ),
630 Self::MissingSource(path) => {
631 write!(f, "gameplay fact '{path}' has no source reference")
632 }
633 Self::EmptyId(field) => write!(f, "gameplay identity '{field}' is empty"),
634 Self::InvalidQuantity { value } => write!(f, "quantity value '{value}' is not finite"),
635 Self::Malformed(message) => write!(f, "malformed gameplay data: {message}"),
636 Self::UnsupportedSchema(version) => {
637 write!(f, "unsupported gameplay data schemaVersion {version}")
638 }
639 Self::DigestMismatch { declared, computed } => write!(
640 f,
641 "gameplay data digest mismatch: declared '{declared}', content '{computed}'"
642 ),
643 }
644 }
645}
646impl std::error::Error for GameplayDataError {}
647
648#[derive(Debug, Clone)]
650pub struct GameplayCatalog {
651 identity: GameplayDatasetIdentity,
652 heroes: Vec<Hero>,
653 by_id: HashMap<HeroId, usize>,
654}
655
656impl GameplayCatalog {
657 pub fn new(
658 identity: GameplayDatasetIdentity,
659 mut heroes: Vec<Hero>,
660 ) -> Result<Self, GameplayDataError> {
661 for (field, value) in [
662 ("datasetId", identity.dataset_id.as_str()),
663 ("version", identity.version.as_str()),
664 ("digest", identity.digest.as_str()),
665 ("source", identity.source.as_str()),
666 ("license", identity.license.as_str()),
667 ("target", identity.target.as_str()),
668 ] {
669 if value.is_empty() {
670 return Err(GameplayDataError::EmptyIdentity(field));
671 }
672 }
673 heroes.sort_by(|left, right| left.id.cmp(&right.id));
674 for hero in &mut heroes {
675 hero.abilities.sort_by(|left, right| {
676 (&left.slot, &left.variant).cmp(&(&right.slot, &right.variant))
677 });
678 }
679 let mut by_id = HashMap::with_capacity(heroes.len());
680 for (index, hero) in heroes.iter().enumerate() {
681 if by_id.insert(hero.id.clone(), index).is_some() {
682 return Err(GameplayDataError::DuplicateHero(hero.id.clone()));
683 }
684 validate_hero(hero)?;
685 }
686 Ok(Self {
687 identity,
688 heroes,
689 by_id,
690 })
691 }
692 pub fn identity(&self) -> &GameplayDatasetIdentity {
693 &self.identity
694 }
695 pub fn heroes(&self) -> &[Hero] {
696 &self.heroes
697 }
698 pub fn hero(&self, id: &HeroId) -> Option<&Hero> {
699 self.by_id.get(id).map(|index| &self.heroes[*index])
700 }
701 pub fn hero_by_id(&self, id: impl AsRef<str>) -> Option<&Hero> {
702 self.hero(&HeroId::new(id.as_ref()))
703 }
704 pub fn ability(&self, reference: &AbilityRef) -> Result<&Ability, AbilityLookupError> {
705 self.hero(reference.hero())
706 .ok_or_else(|| AbilityLookupError::Missing {
707 hero: reference.hero().clone(),
708 slot: reference.slot().clone(),
709 })?
710 .ability_ref(reference.slot(), reference.variant())
711 }
712 pub fn find_abilities_by_keyword(&self, keyword: &str) -> Vec<(&Hero, &Ability)> {
713 self.heroes
714 .iter()
715 .flat_map(|hero| {
716 hero.abilities()
717 .iter()
718 .filter(move |ability| ability.has_keyword(keyword))
719 .map(move |ability| (hero, ability))
720 })
721 .collect()
722 }
723}
724
725fn validate_hero(hero: &Hero) -> Result<(), GameplayDataError> {
726 if hero.id.as_str().is_empty() {
727 return Err(GameplayDataError::EmptyId("hero"));
728 }
729 if hero.sources.is_empty() {
730 return Err(GameplayDataError::MissingSource(format!(
731 "hero {}",
732 hero.id
733 )));
734 }
735 if hero.name.sources.is_empty() {
736 return Err(GameplayDataError::MissingSource(format!(
737 "hero {} name",
738 hero.id
739 )));
740 }
741 validate_sources(&format!("hero {}", hero.id), &hero.sources)?;
742 validate_sources(&format!("hero {} name", hero.id), &hero.name.sources)?;
743 if let Some(role) = &hero.role {
744 if role.value.as_str().is_empty() {
745 return Err(GameplayDataError::EmptyId("hero role"));
746 }
747 validate_fact(&format!("hero {} role", hero.id), role)?;
748 }
749 for (key, fact) in &hero.stats {
750 if key.as_str().is_empty() {
751 return Err(GameplayDataError::EmptyId("hero stat"));
752 }
753 validate_fact(&format!("hero {} stat {}", hero.id, key), fact)?;
754 validate_stat_value(&format!("hero {} stat {}", hero.id, key), &fact.value)?;
755 }
756 let mut slot_variants = BTreeSet::new();
757 let mut slot_counts: BTreeMap<LogicalSlot, usize> = BTreeMap::new();
758 for ability in &hero.abilities {
759 if ability.slot.as_str().trim().is_empty() {
760 return Err(GameplayDataError::EmptyId("ability slot"));
761 }
762 if ability
763 .variant
764 .as_ref()
765 .is_some_and(|variant| variant.as_str().is_empty())
766 {
767 return Err(GameplayDataError::EmptyId("ability variant"));
768 }
769 if ability.sources.is_empty() {
770 return Err(GameplayDataError::MissingSource(format!(
771 "hero {} ability {}",
772 hero.id, ability.slot
773 )));
774 }
775 validate_sources(
776 &format!("hero {} ability {}", hero.id, ability.slot),
777 &ability.sources,
778 )?;
779 if ability.name.sources.is_empty() {
780 return Err(GameplayDataError::MissingSource(format!(
781 "hero {} ability {} name",
782 hero.id, ability.slot
783 )));
784 }
785 validate_sources(
786 &format!("hero {} ability {} name", hero.id, ability.slot),
787 &ability.name.sources,
788 )?;
789 let slot_variant = (ability.slot.clone(), ability.variant.clone());
790 if !slot_variants.insert(slot_variant) {
791 return Err(GameplayDataError::DuplicateSlotVariant {
792 hero: hero.id.clone(),
793 slot: ability.slot.clone(),
794 variant: ability.variant.clone(),
795 });
796 }
797 *slot_counts.entry(ability.slot.clone()).or_default() += 1;
798 for (key, fact) in &ability.stats {
799 if key.as_str().is_empty() {
800 return Err(GameplayDataError::EmptyId("ability stat"));
801 }
802 validate_fact(
803 &format!("hero {} ability {} stat {}", hero.id, ability.slot, key),
804 fact,
805 )?;
806 validate_stat_value(
807 &format!("hero {} ability {} stat {}", hero.id, ability.slot, key),
808 &fact.value,
809 )?;
810 }
811 }
812 for (slot, count) in slot_counts {
813 if count > 1
814 && hero
815 .abilities
816 .iter()
817 .filter(|ability| ability.slot == slot)
818 .any(|ability| ability.variant.is_none())
819 {
820 return Err(GameplayDataError::VariantRequired {
821 hero: hero.id.clone(),
822 slot,
823 });
824 }
825 }
826 Ok(())
827}
828
829fn validate_stat_value(_path: &str, value: &StatValue) -> Result<(), GameplayDataError> {
830 if let StatValue::Quantity(quantity) = value {
831 if !quantity.value.is_finite() {
832 return Err(GameplayDataError::InvalidQuantity {
833 value: quantity.value,
834 });
835 }
836 if quantity.unit.as_str().is_empty() {
837 return Err(GameplayDataError::EmptyId("quantity unit"));
838 }
839 }
840 Ok(())
841}
842
843fn validate_fact<T>(path: &str, fact: &Fact<T>) -> Result<(), GameplayDataError> {
844 if fact.sources.is_empty() {
845 return Err(GameplayDataError::MissingSource(path.to_string()));
846 }
847 validate_sources(path, &fact.sources)
848}
849
850fn validate_sources(path: &str, sources: &[SourceReference]) -> Result<(), GameplayDataError> {
851 for item in sources {
852 if item.source.is_empty() || item.locator.is_empty() {
853 return Err(GameplayDataError::MissingSource(path.to_string()));
854 }
855 }
856 Ok(())
857}