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")]
280#[non_exhaustive]
281pub struct SourceReference {
282    pub source: String,
283    pub locator: String,
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub note: Option<String>,
286}
287
288impl SourceReference {
289    pub fn new(source: impl Into<String>, locator: impl Into<String>) -> Self {
290        Self {
291            source: source.into(),
292            locator: locator.into(),
293            note: None,
294        }
295    }
296    pub fn with_note(mut self, note: impl Into<String>) -> Self {
297        self.note = Some(note.into());
298        self
299    }
300}
301
302/// Identity and source metadata of a gameplay dataset. This is distinct from
303/// the Workshop parser/catalog dataset identity.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306#[non_exhaustive]
307pub struct GameplayDatasetIdentity {
308    pub dataset_id: String,
309    pub version: String,
310    pub digest: String,
311    pub source: String,
312    pub license: String,
313    pub target: String,
314    pub reviewed: bool,
315}
316
317impl GameplayDatasetIdentity {
318    pub fn new(
319        dataset_id: impl Into<String>,
320        version: impl Into<String>,
321        digest: impl Into<String>,
322        source: impl Into<String>,
323        license: impl Into<String>,
324        target: impl Into<String>,
325        reviewed: bool,
326    ) -> Self {
327        Self {
328            dataset_id: dataset_id.into(),
329            version: version.into(),
330            digest: digest.into(),
331            source: source.into(),
332            license: license.into(),
333            target: target.into(),
334            reviewed,
335        }
336    }
337}
338
339/// A gameplay fact tied to source references in the dataset version being consumed.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct Fact<T> {
342    pub value: T,
343    pub sources: Vec<SourceReference>,
344}
345
346impl<T> Fact<T> {
347    pub fn new(value: T, sources: Vec<SourceReference>) -> Self {
348        Self { value, sources }
349    }
350    pub fn value(&self) -> &T {
351        &self.value
352    }
353    pub fn sources(&self) -> &[SourceReference] {
354        &self.sources
355    }
356}
357
358/// A finite numeric quantity with an explicit unit.
359#[derive(Debug, Clone, PartialEq, Serialize)]
360pub struct Quantity {
361    pub value: f64,
362    pub unit: Unit,
363}
364
365impl Quantity {
366    pub fn new(value: f64, unit: Unit) -> Result<Self, GameplayDataError> {
367        if !value.is_finite() {
368            return Err(GameplayDataError::InvalidQuantity { value });
369        }
370        Ok(Self { value, unit })
371    }
372}
373
374impl<'de> Deserialize<'de> for Quantity {
375    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
376    where
377        D: Deserializer<'de>,
378    {
379        #[derive(Deserialize)]
380        struct RawQuantity {
381            value: f64,
382            unit: Unit,
383        }
384        let raw = RawQuantity::deserialize(deserializer)?;
385        Self::new(raw.value, raw.unit).map_err(serde::de::Error::custom)
386    }
387}
388
389/// A typed common or extensible gameplay stat value.
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
392pub enum StatValue {
393    Quantity(Quantity),
394    Text(String),
395    Boolean(bool),
396    Choice(String),
397}
398
399/// An ability record in a logical slot. The hero is supplied by its parent Hero record.
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct Ability {
403    slot: LogicalSlot,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    variant: Option<AbilityVariant>,
406    name: Fact<LocalizedText>,
407    #[serde(default)]
408    keywords: BTreeSet<KeywordId>,
409    #[serde(default)]
410    stats: BTreeMap<StatKey, Fact<StatValue>>,
411    sources: Vec<SourceReference>,
412}
413
414impl Ability {
415    pub fn new(
416        slot: LogicalSlot,
417        variant: Option<AbilityVariant>,
418        name: Fact<LocalizedText>,
419        sources: Vec<SourceReference>,
420    ) -> Self {
421        Self {
422            slot,
423            variant,
424            name,
425            keywords: BTreeSet::new(),
426            stats: BTreeMap::new(),
427            sources,
428        }
429    }
430    pub fn with_keyword(mut self, keyword: impl Into<KeywordId>) -> Self {
431        self.keywords.insert(keyword.into());
432        self
433    }
434    pub fn with_stat(mut self, key: StatKey, value: Fact<StatValue>) -> Self {
435        self.stats.insert(key, value);
436        self
437    }
438    pub fn reference(&self, hero: &HeroId) -> AbilityRef {
439        AbilityRef::new(hero.clone(), self.slot.clone(), self.variant.clone())
440    }
441    pub fn slot(&self) -> &LogicalSlot {
442        &self.slot
443    }
444    pub fn variant(&self) -> Option<&AbilityVariant> {
445        self.variant.as_ref()
446    }
447    pub fn name(&self) -> &Fact<LocalizedText> {
448        &self.name
449    }
450    pub fn keywords(&self) -> impl Iterator<Item = &KeywordId> {
451        self.keywords.iter()
452    }
453    pub fn has_keyword(&self, keyword: &str) -> bool {
454        self.keywords.iter().any(|known| known.as_str() == keyword)
455    }
456    pub fn stat(&self, key: &StatKey) -> Option<&Fact<StatValue>> {
457        self.stats.get(key)
458    }
459    pub fn stats(&self) -> impl Iterator<Item = (&StatKey, &Fact<StatValue>)> {
460        self.stats.iter()
461    }
462    pub fn sources(&self) -> &[SourceReference] {
463        &self.sources
464    }
465}
466
467/// A hero record with a non-uniform ability kit.
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469#[serde(rename_all = "camelCase")]
470pub struct Hero {
471    id: HeroId,
472    name: Fact<LocalizedText>,
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    role: Option<Fact<HeroRole>>,
475    #[serde(default)]
476    stats: BTreeMap<StatKey, Fact<StatValue>>,
477    abilities: Vec<Ability>,
478    sources: Vec<SourceReference>,
479}
480
481impl Hero {
482    pub fn new(
483        id: HeroId,
484        name: Fact<LocalizedText>,
485        abilities: Vec<Ability>,
486        sources: Vec<SourceReference>,
487    ) -> Self {
488        Self {
489            id,
490            name,
491            role: None,
492            stats: BTreeMap::new(),
493            abilities,
494            sources,
495        }
496    }
497    pub fn with_role(mut self, role: Fact<HeroRole>) -> Self {
498        self.role = Some(role);
499        self
500    }
501    pub fn with_stat(mut self, key: StatKey, value: Fact<StatValue>) -> Self {
502        self.stats.insert(key, value);
503        self
504    }
505    pub fn id(&self) -> &HeroId {
506        &self.id
507    }
508    pub fn name(&self) -> &Fact<LocalizedText> {
509        &self.name
510    }
511    pub fn role(&self) -> Option<&Fact<HeroRole>> {
512        self.role.as_ref()
513    }
514    pub fn stat(&self, key: &StatKey) -> Option<&Fact<StatValue>> {
515        self.stats.get(key)
516    }
517    pub fn stats(&self) -> impl Iterator<Item = (&StatKey, &Fact<StatValue>)> {
518        self.stats.iter()
519    }
520    pub fn abilities(&self) -> &[Ability] {
521        &self.abilities
522    }
523    pub fn abilities_in_slot(&self, slot: &LogicalSlot) -> Vec<&Ability> {
524        self.abilities
525            .iter()
526            .filter(|ability| ability.slot() == slot)
527            .collect()
528    }
529    pub fn ability(&self, slot: &LogicalSlot) -> Result<&Ability, AbilityLookupError> {
530        let matches = self.abilities_in_slot(slot);
531        match matches.as_slice() {
532            [] => Err(AbilityLookupError::Missing {
533                hero: self.id.clone(),
534                slot: slot.clone(),
535            }),
536            [ability] => Ok(ability),
537            _ => Err(AbilityLookupError::Ambiguous {
538                hero: self.id.clone(),
539                slot: slot.clone(),
540                candidates: matches
541                    .into_iter()
542                    .map(|ability| ability.reference(&self.id))
543                    .collect(),
544            }),
545        }
546    }
547    pub fn ability_ref(
548        &self,
549        slot: &LogicalSlot,
550        variant: Option<&AbilityVariant>,
551    ) -> Result<&Ability, AbilityLookupError> {
552        match variant {
553            Some(variant) => self.ability_variant(slot, variant),
554            None => self.ability(slot),
555        }
556    }
557    pub fn ability_variant(
558        &self,
559        slot: &LogicalSlot,
560        variant: &AbilityVariant,
561    ) -> Result<&Ability, AbilityLookupError> {
562        self.abilities
563            .iter()
564            .find(|ability| ability.slot() == slot && ability.variant.as_ref() == Some(variant))
565            .ok_or_else(|| AbilityLookupError::MissingVariant {
566                hero: self.id.clone(),
567                slot: slot.clone(),
568                variant: variant.clone(),
569            })
570    }
571    pub fn sources(&self) -> &[SourceReference] {
572        &self.sources
573    }
574}
575
576/// Explicit failure for a logical-slot lookup.
577#[derive(Debug, Clone, PartialEq, Eq)]
578#[non_exhaustive]
579pub enum AbilityLookupError {
580    Missing {
581        hero: HeroId,
582        slot: LogicalSlot,
583    },
584    Ambiguous {
585        hero: HeroId,
586        slot: LogicalSlot,
587        candidates: Vec<AbilityRef>,
588    },
589    MissingVariant {
590        hero: HeroId,
591        slot: LogicalSlot,
592        variant: AbilityVariant,
593    },
594}
595
596impl std::fmt::Display for AbilityLookupError {
597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        match self {
599            Self::Missing { hero, slot } => {
600                write!(f, "hero '{hero}' has no ability in slot '{slot}'")
601            }
602            Self::Ambiguous {
603                hero,
604                slot,
605                candidates,
606            } => write!(
607                f,
608                "hero '{hero}' has multiple abilities in slot '{slot}': {candidates:?}"
609            ),
610            Self::MissingVariant {
611                hero,
612                slot,
613                variant,
614            } => write!(
615                f,
616                "hero '{hero}' has no ability in slot '{slot}' with variant '{variant}'"
617            ),
618        }
619    }
620}
621impl std::error::Error for AbilityLookupError {}
622
623/// Validation and construction errors for gameplay data.
624#[derive(Debug, Clone, PartialEq)]
625#[non_exhaustive]
626pub enum GameplayDataError {
627    EmptyIdentity(&'static str),
628    DuplicateHero(HeroId),
629    DuplicateSlotVariant {
630        hero: HeroId,
631        slot: LogicalSlot,
632        variant: Option<AbilityVariant>,
633    },
634    VariantRequired {
635        hero: HeroId,
636        slot: LogicalSlot,
637    },
638    MissingSource(String),
639    EmptyId(&'static str),
640    InvalidQuantity {
641        value: f64,
642    },
643    Malformed(String),
644    UnsupportedSchema(u32),
645    DigestMismatch {
646        declared: String,
647        computed: String,
648    },
649}
650
651impl std::fmt::Display for GameplayDataError {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        match self {
654            Self::EmptyIdentity(field) => {
655                write!(f, "gameplay dataset identity field '{field}' is empty")
656            }
657            Self::DuplicateHero(id) => write!(f, "duplicate hero identity '{id}'"),
658            Self::DuplicateSlotVariant {
659                hero,
660                slot,
661                variant,
662            } => write!(
663                f,
664                "hero '{hero}' has duplicate slot/variant '{slot}'/'{variant:?}'"
665            ),
666            Self::VariantRequired { hero, slot } => write!(
667                f,
668                "hero '{hero}' has multiple abilities in slot '{slot}' but not every record has a variant"
669            ),
670            Self::MissingSource(path) => {
671                write!(f, "gameplay fact '{path}' has no source reference")
672            }
673            Self::EmptyId(field) => write!(f, "gameplay identity '{field}' is empty"),
674            Self::InvalidQuantity { value } => write!(f, "quantity value '{value}' is not finite"),
675            Self::Malformed(message) => write!(f, "malformed gameplay data: {message}"),
676            Self::UnsupportedSchema(version) => {
677                write!(f, "unsupported gameplay data schemaVersion {version}")
678            }
679            Self::DigestMismatch { declared, computed } => write!(
680                f,
681                "gameplay data digest mismatch: declared '{declared}', content '{computed}'"
682            ),
683        }
684    }
685}
686impl std::error::Error for GameplayDataError {}
687
688/// The validated gameplay dataset and its lookup indexes.
689#[derive(Debug, Clone)]
690pub struct GameplayCatalog {
691    identity: GameplayDatasetIdentity,
692    heroes: Vec<Hero>,
693    by_id: HashMap<HeroId, usize>,
694}
695
696impl GameplayCatalog {
697    pub fn new(
698        identity: GameplayDatasetIdentity,
699        mut heroes: Vec<Hero>,
700    ) -> Result<Self, GameplayDataError> {
701        for (field, value) in [
702            ("datasetId", identity.dataset_id.as_str()),
703            ("version", identity.version.as_str()),
704            ("digest", identity.digest.as_str()),
705            ("source", identity.source.as_str()),
706            ("license", identity.license.as_str()),
707            ("target", identity.target.as_str()),
708        ] {
709            if value.is_empty() {
710                return Err(GameplayDataError::EmptyIdentity(field));
711            }
712        }
713        heroes.sort_by(|left, right| left.id.cmp(&right.id));
714        for hero in &mut heroes {
715            hero.abilities.sort_by(|left, right| {
716                (&left.slot, &left.variant).cmp(&(&right.slot, &right.variant))
717            });
718        }
719        let mut by_id = HashMap::with_capacity(heroes.len());
720        for (index, hero) in heroes.iter().enumerate() {
721            if by_id.insert(hero.id.clone(), index).is_some() {
722                return Err(GameplayDataError::DuplicateHero(hero.id.clone()));
723            }
724            validate_hero(hero)?;
725        }
726        Ok(Self {
727            identity,
728            heroes,
729            by_id,
730        })
731    }
732    pub fn identity(&self) -> &GameplayDatasetIdentity {
733        &self.identity
734    }
735    pub fn heroes(&self) -> &[Hero] {
736        &self.heroes
737    }
738    pub fn hero(&self, id: &HeroId) -> Option<&Hero> {
739        self.by_id.get(id).map(|index| &self.heroes[*index])
740    }
741    pub fn hero_by_id(&self, id: impl AsRef<str>) -> Option<&Hero> {
742        self.hero(&HeroId::new(id.as_ref()))
743    }
744    pub fn ability(&self, reference: &AbilityRef) -> Result<&Ability, AbilityLookupError> {
745        self.hero(reference.hero())
746            .ok_or_else(|| AbilityLookupError::Missing {
747                hero: reference.hero().clone(),
748                slot: reference.slot().clone(),
749            })?
750            .ability_ref(reference.slot(), reference.variant())
751    }
752    pub fn find_abilities_by_keyword(&self, keyword: &str) -> Vec<(&Hero, &Ability)> {
753        self.heroes
754            .iter()
755            .flat_map(|hero| {
756                hero.abilities()
757                    .iter()
758                    .filter(move |ability| ability.has_keyword(keyword))
759                    .map(move |ability| (hero, ability))
760            })
761            .collect()
762    }
763}
764
765fn validate_hero(hero: &Hero) -> Result<(), GameplayDataError> {
766    if hero.id.as_str().is_empty() {
767        return Err(GameplayDataError::EmptyId("hero"));
768    }
769    if hero.sources.is_empty() {
770        return Err(GameplayDataError::MissingSource(format!(
771            "hero {}",
772            hero.id
773        )));
774    }
775    if hero.name.sources.is_empty() {
776        return Err(GameplayDataError::MissingSource(format!(
777            "hero {} name",
778            hero.id
779        )));
780    }
781    validate_sources(&format!("hero {}", hero.id), &hero.sources)?;
782    validate_sources(&format!("hero {} name", hero.id), &hero.name.sources)?;
783    if let Some(role) = &hero.role {
784        if role.value.as_str().is_empty() {
785            return Err(GameplayDataError::EmptyId("hero role"));
786        }
787        validate_fact(&format!("hero {} role", hero.id), role)?;
788    }
789    for (key, fact) in &hero.stats {
790        if key.as_str().is_empty() {
791            return Err(GameplayDataError::EmptyId("hero stat"));
792        }
793        validate_fact(&format!("hero {} stat {}", hero.id, key), fact)?;
794        validate_stat_value(&format!("hero {} stat {}", hero.id, key), &fact.value)?;
795    }
796    let mut slot_variants = BTreeSet::new();
797    let mut slot_counts: BTreeMap<LogicalSlot, usize> = BTreeMap::new();
798    for ability in &hero.abilities {
799        if ability.slot.as_str().trim().is_empty() {
800            return Err(GameplayDataError::EmptyId("ability slot"));
801        }
802        if ability
803            .variant
804            .as_ref()
805            .is_some_and(|variant| variant.as_str().is_empty())
806        {
807            return Err(GameplayDataError::EmptyId("ability variant"));
808        }
809        if ability.sources.is_empty() {
810            return Err(GameplayDataError::MissingSource(format!(
811                "hero {} ability {}",
812                hero.id, ability.slot
813            )));
814        }
815        validate_sources(
816            &format!("hero {} ability {}", hero.id, ability.slot),
817            &ability.sources,
818        )?;
819        if ability.name.sources.is_empty() {
820            return Err(GameplayDataError::MissingSource(format!(
821                "hero {} ability {} name",
822                hero.id, ability.slot
823            )));
824        }
825        validate_sources(
826            &format!("hero {} ability {} name", hero.id, ability.slot),
827            &ability.name.sources,
828        )?;
829        let slot_variant = (ability.slot.clone(), ability.variant.clone());
830        if !slot_variants.insert(slot_variant) {
831            return Err(GameplayDataError::DuplicateSlotVariant {
832                hero: hero.id.clone(),
833                slot: ability.slot.clone(),
834                variant: ability.variant.clone(),
835            });
836        }
837        *slot_counts.entry(ability.slot.clone()).or_default() += 1;
838        for (key, fact) in &ability.stats {
839            if key.as_str().is_empty() {
840                return Err(GameplayDataError::EmptyId("ability stat"));
841            }
842            validate_fact(
843                &format!("hero {} ability {} stat {}", hero.id, ability.slot, key),
844                fact,
845            )?;
846            validate_stat_value(
847                &format!("hero {} ability {} stat {}", hero.id, ability.slot, key),
848                &fact.value,
849            )?;
850        }
851    }
852    for (slot, count) in slot_counts {
853        if count > 1
854            && hero
855                .abilities
856                .iter()
857                .filter(|ability| ability.slot == slot)
858                .any(|ability| ability.variant.is_none())
859        {
860            return Err(GameplayDataError::VariantRequired {
861                hero: hero.id.clone(),
862                slot,
863            });
864        }
865    }
866    Ok(())
867}
868
869fn validate_stat_value(_path: &str, value: &StatValue) -> Result<(), GameplayDataError> {
870    if let StatValue::Quantity(quantity) = value {
871        if !quantity.value.is_finite() {
872            return Err(GameplayDataError::InvalidQuantity {
873                value: quantity.value,
874            });
875        }
876        if quantity.unit.as_str().is_empty() {
877            return Err(GameplayDataError::EmptyId("quantity unit"));
878        }
879    }
880    Ok(())
881}
882
883fn validate_fact<T>(path: &str, fact: &Fact<T>) -> Result<(), GameplayDataError> {
884    if fact.sources.is_empty() {
885        return Err(GameplayDataError::MissingSource(path.to_string()));
886    }
887    validate_sources(path, &fact.sources)
888}
889
890fn validate_sources(path: &str, sources: &[SourceReference]) -> Result<(), GameplayDataError> {
891    for item in sources {
892        if item.source.is_empty() || item.locator.is_empty() {
893            return Err(GameplayDataError::MissingSource(path.to_string()));
894        }
895    }
896    Ok(())
897}