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};
10
11#[cfg(test)]
12use super::reconciliation;
13#[cfg(test)]
14use super::table::TableEntry;
15#[cfg(test)]
16use super::table::{self, KeyKind};
17use super::{PathPart, Settings, SettingsNode};
18
19mod operations;
20mod projection;
21pub use projection::{definition, definitions, definitions_by_id, validate_catalog};
22#[cfg(test)]
23use projection::{validate_enum_projection, validate_raw_projection};
24
25/// A locale-independent Workshop setting concept identity.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub struct SettingId(String);
28
29impl SettingId {
30    pub fn new(value: impl Into<String>) -> Self {
31        Self(value.into())
32    }
33
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl From<&str> for SettingId {
40    fn from(value: &str) -> Self {
41        Self::new(value)
42    }
43}
44
45impl fmt::Display for SettingId {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        formatter.write_str(self.as_str())
48    }
49}
50
51/// Whether a definition has a reviewed canonical concept identity.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum SettingIdentity {
54    Known(SettingId),
55    Unknown,
56}
57
58/// The Workshop-native section that owns a setting.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60#[non_exhaustive]
61pub enum SettingScope {
62    Main,
63    Lobby,
64    GameModes,
65    Heroes,
66    Extensions,
67    Workshop,
68    Unknown,
69}
70
71/// An open team identity used by hero settings structure.
72#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
73pub struct TeamId(String);
74
75impl TeamId {
76    pub fn new(value: impl Into<String>) -> Self {
77        Self(value.into())
78    }
79
80    pub fn as_str(&self) -> &str {
81        &self.0
82    }
83}
84
85/// The semantic entity to which a setting applies.
86#[derive(Debug, Clone, PartialEq, Eq, Hash)]
87pub enum SettingTarget {
88    Global,
89    Mode(String),
90    Team(TeamId),
91    Hero {
92        team: Option<TeamId>,
93        hero: HeroId,
94    },
95    TeamAbility {
96        team: Option<TeamId>,
97        slot: LogicalSlot,
98        variant: Option<AbilityVariant>,
99    },
100    HeroAbility {
101        team: Option<TeamId>,
102        hero: HeroId,
103        slot: LogicalSlot,
104        variant: Option<AbilityVariant>,
105    },
106}
107
108/// The target shape described by a definition. Concrete identities are
109/// supplied separately when applicability is queried.
110#[derive(Debug, Clone, PartialEq, Eq, Hash)]
111pub enum SettingTargetKind {
112    Global,
113    Mode,
114    Team,
115    TeamAbility {
116        slot: LogicalSlot,
117        variant: Option<AbilityVariant>,
118    },
119    Hero,
120    HeroAbility {
121        slot: LogicalSlot,
122        variant: Option<AbilityVariant>,
123    },
124    Unknown,
125}
126
127/// The result of asking whether a definition applies to a target.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Applicability {
130    Applicable,
131    NotApplicable,
132    Unknown,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum NumericBoundsError {
138    NonFinite,
139    Reversed,
140}
141
142/// Source-backed effective numeric bounds. `None` means the current reviewed
143/// source does not establish that bound.
144#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
145pub struct NumericBounds {
146    min: Option<f64>,
147    max: Option<f64>,
148}
149
150impl NumericBounds {
151    pub const fn unknown() -> Self {
152        Self {
153            min: None,
154            max: None,
155        }
156    }
157
158    pub fn new(min: Option<f64>, max: Option<f64>) -> Result<Self, NumericBoundsError> {
159        if min.is_some_and(|value| !value.is_finite())
160            || max.is_some_and(|value| !value.is_finite())
161        {
162            return Err(NumericBoundsError::NonFinite);
163        }
164        if min.zip(max).is_some_and(|(min, max)| min > max) {
165            return Err(NumericBoundsError::Reversed);
166        }
167        Ok(Self { min, max })
168    }
169
170    pub fn min(&self) -> Option<f64> {
171        self.min
172    }
173
174    pub fn max(&self) -> Option<f64> {
175        self.max
176    }
177
178    pub fn effective(&self, authored: f64) -> Option<EffectiveNumber> {
179        if !authored.is_finite() || self.min.is_none() && self.max.is_none() {
180            return None;
181        }
182        match (self.min, self.max) {
183            (Some(min), None) if authored >= min => return None,
184            (None, Some(max)) if authored <= max => return None,
185            _ => {}
186        }
187        let mut effective = authored;
188        if let Some(min) = self.min {
189            effective = effective.max(min);
190        }
191        if let Some(max) = self.max {
192            effective = effective.min(max);
193        }
194        Some(EffectiveNumber {
195            authored,
196            effective,
197        })
198    }
199}
200
201/// An authored numeric value paired with its Workshop-effective value.
202#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
203pub struct EffectiveNumber {
204    pub authored: f64,
205    pub effective: f64,
206}
207
208/// The machine-readable value domain of a setting.
209#[derive(Debug, Clone, PartialEq, PartialOrd)]
210#[non_exhaustive]
211pub enum SettingValueDomain {
212    Boolean,
213    Number(NumericBounds),
214    Percent(NumericBounds),
215    String,
216    Enum { domain: String },
217    HeroList,
218    MapList,
219    PresenceOnly,
220}
221
222impl SettingValueDomain {
223    /// The machine-readable kind name of this domain.
224    pub fn kind(&self) -> &'static str {
225        match self {
226            Self::Boolean => "boolean",
227            Self::Number(_) => "number",
228            Self::Percent(_) => "percent",
229            Self::String => "string",
230            Self::Enum { .. } => "enum",
231            Self::HeroList => "hero-list",
232            Self::MapList => "map-list",
233            Self::PresenceOnly => "presence-only",
234        }
235    }
236}
237
238/// One accepted spelling of a setting enum member.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct SettingEnumMember {
241    domain: &'static str,
242    id: &'static str,
243    english_name: &'static str,
244}
245
246impl SettingEnumMember {
247    pub fn domain(&self) -> &str {
248        self.domain
249    }
250
251    pub fn id(&self) -> &str {
252        self.id
253    }
254
255    pub fn english_name(&self) -> &str {
256        self.english_name
257    }
258}
259
260/// A typed authored value in the settings carrier.
261#[derive(Debug, Clone, PartialEq)]
262pub enum SettingValue {
263    Boolean(bool),
264    Number(f64),
265    Percent(f64),
266    String(String),
267    Enum(String),
268    HeroList(Vec<String>),
269    MapList(Vec<String>),
270    PresenceOnly,
271}
272
273impl SettingValue {
274    /// The machine-readable kind name of this value, mirroring
275    /// [`SettingValueDomain::kind`].
276    pub fn kind(&self) -> &'static str {
277        match self {
278            Self::Boolean(_) => "boolean",
279            Self::Number(_) => "number",
280            Self::Percent(_) => "percent",
281            Self::String(_) => "string",
282            Self::Enum(_) => "enum",
283            Self::HeroList(_) => "hero-list",
284            Self::MapList(_) => "map-list",
285            Self::PresenceOnly => "presence-only",
286        }
287    }
288}
289
290/// A typed occurrence together with a source-backed effective numeric value.
291#[derive(Debug, Clone, PartialEq)]
292pub struct SettingOccurrence {
293    pub authored: SettingValue,
294    pub effective: Option<EffectiveNumber>,
295}
296
297/// One checked replacement in the original Workshop source text.
298///
299/// The edit changes only `range`; [`Self::apply`] refuses a source buffer whose
300/// bytes at that range no longer equal `expected`.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct SettingSourceEdit {
303    edit: crate::core::source::SourceEdit,
304}
305
306/// Failure from a typed settings query or source-preserving edit.
307#[derive(Debug, Clone, PartialEq)]
308#[non_exhaustive]
309pub enum SettingOperationError {
310    NotApplicable {
311        setting: SettingId,
312        target: SettingTarget,
313    },
314    NotFound {
315        setting: SettingId,
316        target: SettingTarget,
317    },
318    ApplicabilityUnknown {
319        setting: SettingId,
320        target: Box<SettingTarget>,
321    },
322    WrongValueKind {
323        setting: SettingId,
324        expected: &'static str,
325        actual: &'static str,
326        span: Option<crate::core::source::Span>,
327    },
328    InvalidValue {
329        setting: SettingId,
330        message: String,
331        span: Option<crate::core::source::Span>,
332    },
333    SourceUnavailable {
334        setting: SettingId,
335    },
336    SourceMismatch,
337}
338
339impl fmt::Display for SettingOperationError {
340    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
341        match self {
342            Self::NotApplicable { setting, target } => {
343                write!(
344                    formatter,
345                    "setting {setting} does not apply to target {target:?}"
346                )
347            }
348            Self::NotFound { setting, target } => {
349                write!(
350                    formatter,
351                    "setting {setting} was not found for target {target:?}"
352                )
353            }
354            Self::ApplicabilityUnknown { setting, target } => write!(
355                formatter,
356                "applicability of setting {setting} is unknown for target {target:?}"
357            ),
358            Self::WrongValueKind {
359                setting,
360                expected,
361                actual,
362                ..
363            } => write!(
364                formatter,
365                "setting {setting} expects {expected} value, got {actual}"
366            ),
367            Self::InvalidValue {
368                setting, message, ..
369            } => write!(formatter, "invalid value for setting {setting}: {message}"),
370            Self::SourceUnavailable { setting } => {
371                write!(formatter, "setting {setting} has no editable source")
372            }
373            Self::SourceMismatch => formatter.write_str("source no longer matches the edit"),
374        }
375    }
376}
377
378impl std::error::Error for SettingOperationError {}
379
380impl SettingValueDomain {
381    /// Apply source-backed effective clamping without changing the authored
382    /// value held by [`super::SettingsNode`].
383    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
384        match self {
385            Self::Number(bounds) | Self::Percent(bounds) => bounds.effective(authored),
386            _ => None,
387        }
388    }
389}
390
391/// Locale-facing names associated with a canonical setting concept.
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct SettingPresentation {
394    pub english_name: &'static str,
395    pub locale_section: &'static str,
396}
397
398/// Source metadata shared by the reviewed table projection.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400#[non_exhaustive]
401pub struct SettingSource {
402    pub kind: SettingSourceKind,
403    pub source: &'static str,
404    pub reviewed: bool,
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum SettingSourceKind {
409    RawWorkshopFixture,
410    WorkshopDataExport,
411}
412
413/// One canonical semantic definition projected from an existing table entry.
414#[derive(Debug, Clone, PartialEq)]
415pub struct SettingDefinition {
416    identity: SettingIdentity,
417    scope: SettingScope,
418    path: String,
419    path_parts: &'static [PathPart<'static>],
420    key: &'static str,
421    target: TargetPattern,
422    domain: SettingValueDomain,
423    enum_domain: Option<&'static str>,
424    presentation: SettingPresentation,
425    source: SettingSource,
426}
427
428#[derive(Debug, Clone, PartialEq)]
429enum TargetPattern {
430    Global,
431    Mode(Option<String>),
432    Team(Option<String>),
433    TeamAbility {
434        team: Option<String>,
435        slot: LogicalSlot,
436        variant: Option<AbilityVariant>,
437    },
438    Hero {
439        team: Option<String>,
440        hero: Option<String>,
441    },
442    HeroAbility {
443        team: Option<String>,
444        hero: Option<String>,
445        slot: LogicalSlot,
446        variant: Option<AbilityVariant>,
447    },
448    Unknown,
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    static DUPLICATE_PATH: [PathPart<'static>; 2] =
456        [PathPart::Part("test"), PathPart::Part("value")];
457    static FIXTURE_ENTRY: TableEntry = TableEntry {
458        path: &DUPLICATE_PATH,
459        workshop_name: "Fixture Value",
460        kind: KeyKind::Bool,
461    };
462    static GENERATED_ENTRY: TableEntry = TableEntry {
463        path: &DUPLICATE_PATH,
464        workshop_name: "Generated Value",
465        kind: KeyKind::Bool,
466    };
467    static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember {
468        domain: "mapRotation",
469        member: "afterAGame",
470        name: "After A Game",
471    };
472    static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember {
473        domain: "mapRotation",
474        member: "afterAGame",
475        name: "After Game",
476    };
477    static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember {
478        domain: "mapRotation",
479        member: "afterMirrorMatch",
480        name: "After A Game",
481    };
482    static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember {
483        domain: "setting_lobby_mapRotation",
484        member: "afterGame",
485        name: "After A Game",
486    };
487
488    fn definition(target: TargetPattern) -> SettingDefinition {
489        SettingDefinition {
490            identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
491            scope: SettingScope::Heroes,
492            path: "heroes.test.value".to_string(),
493            path_parts: &[],
494            key: "value",
495            target,
496            domain: SettingValueDomain::Boolean,
497            enum_domain: None,
498            presentation: SettingPresentation {
499                english_name: "Value",
500                locale_section: "labels",
501            },
502            source: SettingSource {
503                kind: SettingSourceKind::RawWorkshopFixture,
504                source: "test",
505                reviewed: true,
506            },
507        }
508    }
509
510    #[test]
511    fn common_target_narrowing_rejects_team_and_slot_mismatches() {
512        let team = definition(TargetPattern::Team(Some("team1".to_string())));
513        assert_eq!(
514            team.applicability(&SettingTarget::Hero {
515                team: Some(TeamId::new("team2")),
516                hero: HeroId::from(crate::gameplay::hero_ids::ANA),
517            })
518            .expect("applicability"),
519            Applicability::NotApplicable
520        );
521
522        let team_ability = definition(TargetPattern::TeamAbility {
523            team: Some("team1".to_string()),
524            slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
525            variant: None,
526        });
527        let target = SettingTarget::HeroAbility {
528            team: Some(TeamId::new("team2")),
529            hero: HeroId::from(crate::gameplay::hero_ids::DVA),
530            slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
531            variant: Some(AbilityVariant::new("mech")),
532        };
533        assert_eq!(
534            team_ability.applicability(&target).expect("applicability"),
535            Applicability::NotApplicable
536        );
537    }
538
539    #[test]
540    fn raw_projection_conflicts_include_presentation_contract() {
541        let errors = validate_raw_projection([
542            table::ProjectedEntry {
543                source: table::ProjectionSource::FixtureTable,
544                entry: &FIXTURE_ENTRY,
545            },
546            table::ProjectedEntry {
547                source: table::ProjectionSource::WorkshopDataExport,
548                entry: &GENERATED_ENTRY,
549            },
550        ]);
551        assert_eq!(errors.len(), 1);
552        assert!(errors[0].contains("fixture table"));
553        assert!(errors[0].contains("Workshop-data export"));
554    }
555
556    #[test]
557    fn enum_projection_conflicts_are_not_hidden_by_lookup_order() {
558        let errors =
559            validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]);
560        assert_eq!(errors.len(), 1);
561        assert!(errors[0].contains("mapRotation.afterAGame"));
562    }
563
564    #[test]
565    fn enum_projection_rejects_display_name_to_identity_collisions() {
566        let errors =
567            validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]);
568        assert_eq!(errors.len(), 1);
569        assert!(errors[0].contains("conflicting settings enum display name"));
570    }
571
572    #[test]
573    fn enum_projection_reconciles_export_members_to_canonical_identities() {
574        let mappings = [reconciliation::EnumMemberMapping {
575            source_domain: "setting_lobby_mapRotation".to_string(),
576            source_member: "afterGame".to_string(),
577            target_domain: "mapRotation".to_string(),
578            target_member: "afterAGame".to_string(),
579        }];
580        let errors =
581            validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings);
582        assert!(errors.is_empty(), "{errors:?}");
583    }
584}