Skip to main content

workshop_rs/settings/
schema.rs

1//! Canonical typed facts for Workshop custom-game settings.
2//!
3//! Definitions are a semantic projection of the reviewed settings table. The
4//! table remains the parser/emitter lookup source, while [`Settings`] and
5//! [`SettingsNode`] remain the source-preserving authored-value carrier.
6
7use std::fmt;
8
9use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot};
10use crate::{gameplay::GameplayDataError, gameplay_data};
11
12use super::table::{self, KeyKind, PathPart, TableEntry};
13
14/// A locale-independent Workshop setting concept identity.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct SettingId(String);
17
18impl SettingId {
19    pub fn new(value: impl Into<String>) -> Self {
20        Self(value.into())
21    }
22
23    pub fn as_str(&self) -> &str {
24        &self.0
25    }
26}
27
28impl From<&str> for SettingId {
29    fn from(value: &str) -> Self {
30        Self::new(value)
31    }
32}
33
34impl fmt::Display for SettingId {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter.write_str(self.as_str())
37    }
38}
39
40/// Whether a definition has a reviewed canonical concept identity.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum SettingIdentity {
43    Known(SettingId),
44    Unknown,
45}
46
47/// The Workshop-native section that owns a setting.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum SettingScope {
50    Main,
51    Lobby,
52    GameModes,
53    Heroes,
54    Extensions,
55    Workshop,
56    Unknown,
57}
58
59/// An open team identity used by hero settings structure.
60#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
61pub struct TeamId(String);
62
63impl TeamId {
64    pub fn new(value: impl Into<String>) -> Self {
65        Self(value.into())
66    }
67
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73/// The semantic entity to which a setting applies.
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub enum SettingTarget {
76    Global,
77    Mode(String),
78    Team(TeamId),
79    Hero {
80        team: Option<TeamId>,
81        hero: HeroId,
82    },
83    TeamAbility {
84        team: Option<TeamId>,
85        slot: LogicalSlot,
86        variant: Option<AbilityVariant>,
87    },
88    HeroAbility {
89        team: Option<TeamId>,
90        hero: HeroId,
91        slot: LogicalSlot,
92        variant: Option<AbilityVariant>,
93    },
94}
95
96/// The target shape described by a definition. Concrete identities are
97/// supplied separately when applicability is queried.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum SettingTargetKind {
100    Global,
101    Mode,
102    Team,
103    TeamAbility {
104        slot: LogicalSlot,
105        variant: Option<AbilityVariant>,
106    },
107    Hero,
108    HeroAbility {
109        slot: LogicalSlot,
110        variant: Option<AbilityVariant>,
111    },
112    Unknown,
113}
114
115/// The result of asking whether a definition applies to a target.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Applicability {
118    Applicable,
119    NotApplicable,
120    Unknown,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum NumericBoundsError {
125    NonFinite,
126    Reversed,
127}
128
129/// Evidence-backed effective numeric bounds. `None` means the current
130/// reviewed evidence does not establish that bound.
131#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
132pub struct NumericBounds {
133    min: Option<f64>,
134    max: Option<f64>,
135}
136
137impl NumericBounds {
138    pub const fn unknown() -> Self {
139        Self {
140            min: None,
141            max: None,
142        }
143    }
144
145    pub fn new(min: Option<f64>, max: Option<f64>) -> Result<Self, NumericBoundsError> {
146        if min.is_some_and(|value| !value.is_finite())
147            || max.is_some_and(|value| !value.is_finite())
148        {
149            return Err(NumericBoundsError::NonFinite);
150        }
151        if min.zip(max).is_some_and(|(min, max)| min > max) {
152            return Err(NumericBoundsError::Reversed);
153        }
154        Ok(Self { min, max })
155    }
156
157    pub fn min(&self) -> Option<f64> {
158        self.min
159    }
160
161    pub fn max(&self) -> Option<f64> {
162        self.max
163    }
164
165    pub fn effective(&self, authored: f64) -> Option<EffectiveNumber> {
166        if !authored.is_finite() || self.min.is_none() && self.max.is_none() {
167            return None;
168        }
169        match (self.min, self.max) {
170            (Some(min), None) if authored >= min => return None,
171            (None, Some(max)) if authored <= max => return None,
172            _ => {}
173        }
174        let mut effective = authored;
175        if let Some(min) = self.min {
176            effective = effective.max(min);
177        }
178        if let Some(max) = self.max {
179            effective = effective.min(max);
180        }
181        Some(EffectiveNumber {
182            authored,
183            effective,
184        })
185    }
186}
187
188/// An authored numeric value paired with its Workshop-effective value.
189#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
190pub struct EffectiveNumber {
191    pub authored: f64,
192    pub effective: f64,
193}
194
195/// The machine-readable value domain of a setting.
196#[derive(Debug, Clone, PartialEq, PartialOrd)]
197pub enum SettingValueDomain {
198    Boolean,
199    Number(NumericBounds),
200    Percent(NumericBounds),
201    String,
202    Enum { domain: String },
203    HeroList,
204    MapList,
205    PresenceOnly,
206}
207
208impl SettingValueDomain {
209    /// Apply evidenced effective clamping without changing the authored
210    /// value held by [`super::SettingsNode`].
211    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
212        match self {
213            Self::Number(bounds) | Self::Percent(bounds) => bounds.effective(authored),
214            _ => None,
215        }
216    }
217}
218
219/// Locale-facing names associated with a canonical setting concept.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct SettingPresentation {
222    pub english_name: &'static str,
223    pub locale_section: &'static str,
224}
225
226impl SettingPresentation {
227    pub fn localized_name(&self, locale: &str) -> Option<&'static str> {
228        if locale.eq_ignore_ascii_case("en-US") {
229            Some(self.english_name)
230        } else {
231            table::localized_name(locale, self.locale_section, self.english_name)
232        }
233    }
234}
235
236/// Provenance shared by the reviewed table projection.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct SettingProvenance {
239    pub kind: SettingEvidenceKind,
240    pub source: &'static str,
241    pub reviewed: bool,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum SettingEvidenceKind {
246    RawWorkshopFixture,
247    WorkshopDataExport,
248}
249
250/// One canonical semantic definition projected from an existing table entry.
251#[derive(Debug, Clone, PartialEq)]
252pub struct SettingDefinition {
253    identity: SettingIdentity,
254    scope: SettingScope,
255    path: String,
256    key: &'static str,
257    target: TargetPattern,
258    domain: SettingValueDomain,
259    presentation: SettingPresentation,
260    provenance: SettingProvenance,
261}
262
263impl SettingDefinition {
264    pub fn identity(&self) -> &SettingIdentity {
265        &self.identity
266    }
267
268    pub fn id(&self) -> Option<&SettingId> {
269        match &self.identity {
270            SettingIdentity::Known(id) => Some(id),
271            SettingIdentity::Unknown => None,
272        }
273    }
274
275    pub fn scope(&self) -> SettingScope {
276        self.scope
277    }
278
279    pub fn path(&self) -> &str {
280        &self.path
281    }
282
283    pub fn domain(&self) -> &SettingValueDomain {
284        &self.domain
285    }
286
287    pub fn target_kind(&self) -> SettingTargetKind {
288        match &self.target {
289            TargetPattern::Global => SettingTargetKind::Global,
290            TargetPattern::Mode(_) => SettingTargetKind::Mode,
291            TargetPattern::Team(_) => SettingTargetKind::Team,
292            TargetPattern::TeamAbility { slot, variant, .. } => SettingTargetKind::TeamAbility {
293                slot: slot.clone(),
294                variant: variant.clone(),
295            },
296            TargetPattern::Hero { .. } => SettingTargetKind::Hero,
297            TargetPattern::HeroAbility { slot, variant, .. } => SettingTargetKind::HeroAbility {
298                slot: slot.clone(),
299                variant: variant.clone(),
300            },
301            TargetPattern::Unknown => SettingTargetKind::Unknown,
302        }
303    }
304
305    pub fn presentation(&self) -> &SettingPresentation {
306        &self.presentation
307    }
308
309    pub fn localized_name(
310        &self,
311        locale: &str,
312        target: &SettingTarget,
313    ) -> Result<Option<&'static str>, GameplayDataError> {
314        match target {
315            SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
316                if self.applicability(target)? == Applicability::NotApplicable {
317                    Ok(None)
318                } else {
319                    Ok(table::hero_setting_name(hero.as_str(), self.key, locale)
320                        .or_else(|| self.presentation.localized_name(locale)))
321                }
322            }
323            _ => Ok(self.presentation.localized_name(locale)),
324        }
325    }
326
327    pub fn provenance(&self) -> SettingProvenance {
328        self.provenance
329    }
330
331    /// Query effective applicability without exposing table deduplication.
332    pub fn applicability(
333        &self,
334        target: &SettingTarget,
335    ) -> Result<Applicability, GameplayDataError> {
336        Ok(match (&self.target, target) {
337            (TargetPattern::Global, SettingTarget::Global) => Applicability::Applicable,
338            (TargetPattern::Mode(expected), SettingTarget::Mode(actual)) => {
339                if expected
340                    .as_deref()
341                    .is_none_or(|expected| expected == actual)
342                {
343                    Applicability::Applicable
344                } else {
345                    Applicability::NotApplicable
346                }
347            }
348            (TargetPattern::Team(expected), SettingTarget::Team(actual)) => {
349                if expected
350                    .as_deref()
351                    .is_none_or(|expected| expected == actual.as_str())
352                {
353                    Applicability::Applicable
354                } else {
355                    Applicability::NotApplicable
356                }
357            }
358            (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => {
359                if team_matches(expected.as_deref(), team.as_ref()) {
360                    Applicability::Unknown
361                } else {
362                    Applicability::NotApplicable
363                }
364            }
365            (
366                TargetPattern::TeamAbility {
367                    team,
368                    slot,
369                    variant: expected_variant,
370                },
371                SettingTarget::TeamAbility {
372                    team: actual_team,
373                    slot: actual_slot,
374                    variant: actual_variant,
375                },
376            ) => {
377                if !team_matches(team.as_deref(), actual_team.as_ref())
378                    || slot != actual_slot
379                    || expected_variant
380                        .as_ref()
381                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
382                {
383                    Applicability::NotApplicable
384                } else {
385                    Applicability::Applicable
386                }
387            }
388            (
389                TargetPattern::TeamAbility {
390                    team,
391                    slot,
392                    variant: expected_variant,
393                },
394                SettingTarget::HeroAbility {
395                    team: actual_team,
396                    hero: actual_hero,
397                    slot: actual_slot,
398                    variant: actual_variant,
399                },
400            ) => {
401                if !team_matches(team.as_deref(), actual_team.as_ref())
402                    || slot != actual_slot
403                    || expected_variant
404                        .as_ref()
405                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
406                {
407                    Applicability::NotApplicable
408                } else {
409                    match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? {
410                        Some(true) => Applicability::Unknown,
411                        Some(false) => Applicability::NotApplicable,
412                        None => Applicability::Unknown,
413                    }
414                }
415            }
416            (
417                TargetPattern::Hero { team, hero },
418                SettingTarget::Hero {
419                    team: actual_team,
420                    hero: actual_hero,
421                },
422            ) => {
423                if !team_matches(team.as_deref(), actual_team.as_ref())
424                    || hero
425                        .as_deref()
426                        .is_some_and(|expected| expected != actual_hero.as_str())
427                {
428                    Applicability::NotApplicable
429                } else {
430                    Applicability::Unknown
431                }
432            }
433            (
434                TargetPattern::HeroAbility {
435                    team,
436                    hero,
437                    slot,
438                    variant: expected_variant,
439                },
440                SettingTarget::HeroAbility {
441                    team: actual_team,
442                    hero: actual_hero,
443                    slot: actual_slot,
444                    ..
445                },
446            ) => {
447                if !team_matches(team.as_deref(), actual_team.as_ref())
448                    || hero
449                        .as_deref()
450                        .is_some_and(|expected| expected != actual_hero.as_str())
451                    || slot.as_str() != actual_slot.as_str()
452                    || expected_variant
453                        .as_ref()
454                        .is_some_and(|expected| Some(expected) != target_variant(target))
455                {
456                    return Ok(Applicability::NotApplicable);
457                }
458                match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? {
459                    None => Applicability::Unknown,
460                    Some(false) => Applicability::NotApplicable,
461                    Some(true) => Applicability::Unknown,
462                }
463            }
464            (TargetPattern::Unknown, _) => Applicability::Unknown,
465            _ => Applicability::NotApplicable,
466        })
467    }
468
469    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
470        self.domain.effective_number(authored)
471    }
472}
473
474#[derive(Debug, Clone, PartialEq)]
475enum TargetPattern {
476    Global,
477    Mode(Option<String>),
478    Team(Option<String>),
479    TeamAbility {
480        team: Option<String>,
481        slot: LogicalSlot,
482        variant: Option<AbilityVariant>,
483    },
484    Hero {
485        team: Option<String>,
486        hero: Option<String>,
487    },
488    HeroAbility {
489        team: Option<String>,
490        hero: Option<String>,
491        slot: LogicalSlot,
492        variant: Option<AbilityVariant>,
493    },
494    Unknown,
495}
496
497fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
498    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
499}
500
501fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
502    match target {
503        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
504        _ => None,
505    }
506}
507
508fn hero_ability_exists(
509    hero: &HeroId,
510    slot: &LogicalSlot,
511    variant: Option<&AbilityVariant>,
512) -> Result<Option<bool>, GameplayDataError> {
513    gameplay_data::builtin_ref()
514        .map_err(Clone::clone)
515        .map(|catalog| {
516            catalog.hero(hero).map(|hero| match variant {
517                Some(variant) => hero.ability_variant(slot, variant).is_ok(),
518                None => !hero.abilities_in_slot(slot).is_empty(),
519            })
520        })
521}
522
523/// Project all currently reviewed table entries into the canonical semantic
524/// model. This is intentionally a projection, not a second settings catalog.
525pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
526    table::entries().map(SettingDefinition::from_entry)
527}
528
529/// Project one reviewed table entry into the canonical semantic definition.
530pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
531    table::lookup(path).map(SettingDefinition::from_entry)
532}
533
534impl SettingDefinition {
535    fn from_entry(entry: &TableEntry) -> Self {
536        let scope = scope_for(entry.path);
537        let key = entry
538            .path
539            .last()
540            .and_then(|part| match part {
541                PathPart::Part(key) => Some(*key),
542                _ => None,
543            })
544            .unwrap_or("");
545        let target = target_for(entry.path);
546        let path = table::path_string(entry.path);
547        let domain = domain_for(entry.kind);
548        let identity = canonical_id(scope, key, entry.path)
549            .map(SettingIdentity::Known)
550            .unwrap_or(SettingIdentity::Unknown);
551        Self {
552            identity,
553            scope,
554            path,
555            key,
556            target,
557            domain,
558            presentation: SettingPresentation {
559                english_name: entry.workshop_name,
560                locale_section: "labels",
561            },
562            provenance: SettingProvenance {
563                kind: if table::is_generated_entry(entry) {
564                    SettingEvidenceKind::WorkshopDataExport
565                } else {
566                    SettingEvidenceKind::RawWorkshopFixture
567                },
568                source: if table::is_generated_entry(entry) {
569                    "workshop-data/workshop-data.json"
570                } else {
571                    "pinned raw Workshop settings fixtures"
572                },
573                reviewed: true,
574            },
575        }
576    }
577}
578
579fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
580    match path.first() {
581        Some(PathPart::Part("main")) => SettingScope::Main,
582        Some(PathPart::Part("lobby")) => SettingScope::Lobby,
583        Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
584        Some(PathPart::Part("heroes")) => SettingScope::Heroes,
585        Some(PathPart::Part("extensions")) => SettingScope::Extensions,
586        Some(PathPart::Part("workshop")) => SettingScope::Workshop,
587        _ => SettingScope::Unknown,
588    }
589}
590
591fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
592    match path {
593        [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
594        [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
595            TargetPattern::Mode(Some((*mode).to_string()))
596        }
597        [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
598        [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
599            target_for_hero(path, None)
600        }
601        [
602            PathPart::Part("heroes"),
603            PathPart::Part(team),
604            PathPart::Hero,
605            ..,
606        ] => target_for_hero(path, Some((*team).to_string())),
607        [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
608        [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
609            target_for_team(path, Some((*team).to_string()))
610        }
611        [
612            PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
613            ..,
614        ] => TargetPattern::Global,
615        _ => TargetPattern::Unknown,
616    }
617}
618
619fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
620    match semantic_ability_slot_for_path(path) {
621        Some(slot) => TargetPattern::TeamAbility {
622            team,
623            slot: LogicalSlot::new(slot),
624            variant: None,
625        },
626        None => TargetPattern::Team(team),
627    }
628}
629
630fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
631    let slot = semantic_ability_slot_for_path(path).map(str::to_string);
632    match slot {
633        Some(slot) => TargetPattern::HeroAbility {
634            team,
635            hero: None,
636            slot: LogicalSlot::new(slot),
637            variant: None,
638        },
639        None => TargetPattern::Hero { team, hero: None },
640    }
641}
642
643fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
644    match path.last() {
645        Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
646        Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
647        Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
648        Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
649        Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
650        Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
651        Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
652        Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
653        _ => table::ability_slot_for_path(path),
654    }
655}
656
657fn domain_for(kind: KeyKind) -> SettingValueDomain {
658    match kind {
659        KeyKind::Flag => SettingValueDomain::PresenceOnly,
660        KeyKind::String => SettingValueDomain::String,
661        KeyKind::Bool => SettingValueDomain::Boolean,
662        KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
663        KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
664        KeyKind::Enum(domain) => SettingValueDomain::Enum {
665            domain: domain.to_string(),
666        },
667        KeyKind::ListMap => SettingValueDomain::MapList,
668        KeyKind::ListHero => SettingValueDomain::HeroList,
669    }
670}
671
672fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
673    let prefix = match scope {
674        SettingScope::Main => "main",
675        SettingScope::Lobby => "lobby",
676        SettingScope::GameModes => "gameMode",
677        SettingScope::Heroes => "hero",
678        SettingScope::Extensions => "extension",
679        SettingScope::Workshop => "workshop",
680        SettingScope::Unknown => "unknown",
681    };
682    if matches!(scope, SettingScope::Unknown)
683        || matches!(scope, SettingScope::Heroes) && semantic_ability_slot_for_path(path).is_some()
684    {
685        return None;
686    }
687    Some(SettingId::new(format!(
688        "setting.{prefix}.{}",
689        key.trim_end_matches('%')
690    )))
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    fn definition(target: TargetPattern) -> SettingDefinition {
698        SettingDefinition {
699            identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
700            scope: SettingScope::Heroes,
701            path: "heroes.test.value".to_string(),
702            key: "value",
703            target,
704            domain: SettingValueDomain::Boolean,
705            presentation: SettingPresentation {
706                english_name: "Value",
707                locale_section: "labels",
708            },
709            provenance: SettingProvenance {
710                kind: SettingEvidenceKind::RawWorkshopFixture,
711                source: "test",
712                reviewed: true,
713            },
714        }
715    }
716
717    #[test]
718    fn common_target_narrowing_rejects_team_and_slot_mismatches() {
719        let team = definition(TargetPattern::Team(Some("team1".to_string())));
720        assert_eq!(
721            team.applicability(&SettingTarget::Hero {
722                team: Some(TeamId::new("team2")),
723                hero: HeroId::from(crate::gameplay::hero_ids::ANA),
724            })
725            .expect("applicability"),
726            Applicability::NotApplicable
727        );
728
729        let team_ability = definition(TargetPattern::TeamAbility {
730            team: Some("team1".to_string()),
731            slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
732            variant: None,
733        });
734        let target = SettingTarget::HeroAbility {
735            team: Some(TeamId::new("team2")),
736            hero: HeroId::from(crate::gameplay::hero_ids::DVA),
737            slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
738            variant: Some(AbilityVariant::new("mech")),
739        };
740        assert_eq!(
741            team_ability.applicability(&target).expect("applicability"),
742            Applicability::NotApplicable
743        );
744    }
745}