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