Skip to main content

workshop_rs/settings/
schema.rs

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