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