Skip to main content

workshop_rs/gameplay/
mod.rs

1//! Canonical hero and ability gameplay data.
2//!
3//! This module owns the data contract used by gameplay-aware tooling. It is
4//! deliberately independent from the Workshop [`crate::catalog`] identity
5//! and from any source-language provider. Ability identity is the open
6//! `hero + logical slot + optional hero-local variant` tuple; display names
7//! are localized source metadata and are not semantic identity.
8
9pub mod data;
10pub mod query;
11
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13
14use serde::{Deserialize, Deserializer, Serialize};
15
16/// Canonical hero identity constants for the current roster. The identity
17/// remains open; these symbols are ergonomic accessors, not a closed enum.
18pub 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/// The canonical, serializable identity of an ability record.
132#[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
161/// Typed constants for stable logical slot classifications.
162pub 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
173/// Common stat identity constants. Long-tail stats remain open string IDs.
174pub 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
184/// Common unit identity constants. New units can be represented without an enum change.
185pub 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/// A deterministic set of localized display strings.
199#[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/// A machine-identifiable source reference for a gameplay fact.
225#[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/// Identity and source metadata of a gameplay dataset. This is distinct from
250/// the Workshop parser/catalog dataset identity.
251#[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/// A gameplay fact tied to source references in the dataset version being consumed.
287#[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/// A finite numeric quantity with an explicit unit.
306#[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/// A typed common or extensible gameplay stat value.
337#[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/// An ability record in a logical slot. The hero is supplied by its parent Hero record.
347#[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/// A hero record with a non-uniform ability kit.
415#[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/// Explicit failure for a logical-slot lookup.
524#[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/// Validation and construction errors for gameplay data.
571#[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/// The validated gameplay dataset and its lookup indexes.
636#[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}