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/// A canonical hero identity. The value is stable within the gameplay data
17/// contract and is not a closed Rust enum.
18#[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/// A typed constant reference for a canonical hero identity.
52#[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
70/// Canonical hero identity constants for the current roster. The identity
71/// remains open; these symbols are ergonomic accessors, not a closed enum.
72pub 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/// The canonical, serializable identity of an ability record.
185#[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
214/// Typed constants for stable logical slot classifications.
215pub 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
226/// Common stat identity constants. Long-tail stats remain open string IDs.
227pub 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
237/// Common unit identity constants. New units can be represented without an enum change.
238pub 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/// A deterministic set of localized display strings.
252#[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/// A machine-identifiable source reference for a gameplay fact.
278#[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/// Identity and source metadata of a gameplay dataset. This is distinct from
288/// the Workshop parser/catalog dataset identity.
289#[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/// A gameplay fact tied to source references in the dataset version being consumed.
302#[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/// A finite numeric quantity with an explicit unit.
321#[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/// A typed common or extensible gameplay stat value.
352#[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/// An ability record in a logical slot. The hero is supplied by its parent Hero record.
362#[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/// A hero record with a non-uniform ability kit.
430#[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/// Explicit failure for a logical-slot lookup.
539#[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/// Validation and construction errors for gameplay data.
585#[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/// The validated gameplay dataset and its lookup indexes.
649#[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}