Skip to main content

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