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, evidence-backed 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 evidence reference for a gameplay fact.
278#[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/// Identity and provenance of a gameplay dataset. This is distinct from the Workshop parser/catalog dataset identity.
288#[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/// A gameplay fact tied to evidence in the dataset version being consumed.
301#[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/// A finite numeric quantity with an explicit unit.
320#[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/// A typed common or extensible gameplay stat value.
351#[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/// An ability record in a logical slot. The hero is supplied by its parent Hero record.
361#[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/// A hero record with a non-uniform ability kit.
429#[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/// Explicit failure for a logical-slot lookup.
538#[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/// Validation and construction errors for gameplay data.
584#[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/// The validated gameplay dataset and its lookup indexes.
646#[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}