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, ops::Range};
8
9use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot};
10use crate::gameplay::{GameplayDataError, data};
11
12use super::reconciliation;
13use super::table::{self, KeyKind, TableEntry};
14use super::{PathPart, Settings, SettingsNode};
15
16/// A locale-independent Workshop setting concept identity.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct SettingId(String);
19
20impl SettingId {
21    pub fn new(value: impl Into<String>) -> Self {
22        Self(value.into())
23    }
24
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28}
29
30impl From<&str> for SettingId {
31    fn from(value: &str) -> Self {
32        Self::new(value)
33    }
34}
35
36impl fmt::Display for SettingId {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        formatter.write_str(self.as_str())
39    }
40}
41
42/// Whether a definition has a reviewed canonical concept identity.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum SettingIdentity {
45    Known(SettingId),
46    Unknown,
47}
48
49/// The Workshop-native section that owns a setting.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum SettingScope {
53    Main,
54    Lobby,
55    GameModes,
56    Heroes,
57    Extensions,
58    Workshop,
59    Unknown,
60}
61
62/// An open team identity used by hero settings structure.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
64pub struct TeamId(String);
65
66impl TeamId {
67    pub fn new(value: impl Into<String>) -> Self {
68        Self(value.into())
69    }
70
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76/// The semantic entity to which a setting applies.
77#[derive(Debug, Clone, PartialEq, Eq, Hash)]
78pub enum SettingTarget {
79    Global,
80    Mode(String),
81    Team(TeamId),
82    Hero {
83        team: Option<TeamId>,
84        hero: HeroId,
85    },
86    TeamAbility {
87        team: Option<TeamId>,
88        slot: LogicalSlot,
89        variant: Option<AbilityVariant>,
90    },
91    HeroAbility {
92        team: Option<TeamId>,
93        hero: HeroId,
94        slot: LogicalSlot,
95        variant: Option<AbilityVariant>,
96    },
97}
98
99/// The target shape described by a definition. Concrete identities are
100/// supplied separately when applicability is queried.
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub enum SettingTargetKind {
103    Global,
104    Mode,
105    Team,
106    TeamAbility {
107        slot: LogicalSlot,
108        variant: Option<AbilityVariant>,
109    },
110    Hero,
111    HeroAbility {
112        slot: LogicalSlot,
113        variant: Option<AbilityVariant>,
114    },
115    Unknown,
116}
117
118/// The result of asking whether a definition applies to a target.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum Applicability {
121    Applicable,
122    NotApplicable,
123    Unknown,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum NumericBoundsError {
129    NonFinite,
130    Reversed,
131}
132
133/// Source-backed effective numeric bounds. `None` means the current reviewed
134/// source does not establish that bound.
135#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
136pub struct NumericBounds {
137    min: Option<f64>,
138    max: Option<f64>,
139}
140
141impl NumericBounds {
142    pub const fn unknown() -> Self {
143        Self {
144            min: None,
145            max: None,
146        }
147    }
148
149    pub fn new(min: Option<f64>, max: Option<f64>) -> Result<Self, NumericBoundsError> {
150        if min.is_some_and(|value| !value.is_finite())
151            || max.is_some_and(|value| !value.is_finite())
152        {
153            return Err(NumericBoundsError::NonFinite);
154        }
155        if min.zip(max).is_some_and(|(min, max)| min > max) {
156            return Err(NumericBoundsError::Reversed);
157        }
158        Ok(Self { min, max })
159    }
160
161    pub fn min(&self) -> Option<f64> {
162        self.min
163    }
164
165    pub fn max(&self) -> Option<f64> {
166        self.max
167    }
168
169    pub fn effective(&self, authored: f64) -> Option<EffectiveNumber> {
170        if !authored.is_finite() || self.min.is_none() && self.max.is_none() {
171            return None;
172        }
173        match (self.min, self.max) {
174            (Some(min), None) if authored >= min => return None,
175            (None, Some(max)) if authored <= max => return None,
176            _ => {}
177        }
178        let mut effective = authored;
179        if let Some(min) = self.min {
180            effective = effective.max(min);
181        }
182        if let Some(max) = self.max {
183            effective = effective.min(max);
184        }
185        Some(EffectiveNumber {
186            authored,
187            effective,
188        })
189    }
190}
191
192/// An authored numeric value paired with its Workshop-effective value.
193#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
194pub struct EffectiveNumber {
195    pub authored: f64,
196    pub effective: f64,
197}
198
199/// The machine-readable value domain of a setting.
200#[derive(Debug, Clone, PartialEq, PartialOrd)]
201#[non_exhaustive]
202pub enum SettingValueDomain {
203    Boolean,
204    Number(NumericBounds),
205    Percent(NumericBounds),
206    String,
207    Enum { domain: String },
208    HeroList,
209    MapList,
210    PresenceOnly,
211}
212
213/// One accepted spelling of a setting enum member.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct SettingEnumMember {
216    domain: &'static str,
217    id: &'static str,
218    english_name: &'static str,
219}
220
221impl SettingEnumMember {
222    pub fn domain(&self) -> &str {
223        self.domain
224    }
225
226    pub fn id(&self) -> &str {
227        self.id
228    }
229
230    pub fn english_name(&self) -> &str {
231        self.english_name
232    }
233}
234
235/// A typed authored value in the settings carrier.
236#[derive(Debug, Clone, PartialEq)]
237pub enum SettingValue {
238    Boolean(bool),
239    Number(f64),
240    Percent(f64),
241    String(String),
242    Enum(String),
243    HeroList(Vec<String>),
244    MapList(Vec<String>),
245    PresenceOnly,
246}
247
248/// A typed occurrence together with a source-backed effective numeric value.
249#[derive(Debug, Clone, PartialEq)]
250pub struct SettingOccurrence {
251    pub authored: SettingValue,
252    pub effective: Option<EffectiveNumber>,
253}
254
255/// One checked replacement in the original Workshop source text.
256///
257/// The edit changes only `range`; [`Self::apply`] refuses a source buffer whose
258/// bytes at that range no longer equal `expected`.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct SettingSourceEdit {
261    range: Range<usize>,
262    expected: String,
263    replacement: String,
264}
265
266impl SettingSourceEdit {
267    pub fn range(&self) -> Range<usize> {
268        self.range.clone()
269    }
270
271    pub fn replacement(&self) -> &str {
272        &self.replacement
273    }
274
275    /// Apply this edit when the targeted source bytes are unchanged.
276    pub fn apply(&self, source: &str) -> Result<String, SettingOperationError> {
277        if source
278            .get(self.range.clone())
279            .is_none_or(|actual| actual != self.expected)
280        {
281            return Err(SettingOperationError::SourceMismatch);
282        }
283        let mut edited =
284            String::with_capacity(source.len() - self.expected.len() + self.replacement.len());
285        edited.push_str(&source[..self.range.start]);
286        edited.push_str(&self.replacement);
287        edited.push_str(&source[self.range.end..]);
288        Ok(edited)
289    }
290}
291
292/// Failure from a typed settings query or source-preserving edit.
293#[derive(Debug, Clone, PartialEq)]
294#[non_exhaustive]
295pub enum SettingOperationError {
296    NotApplicable {
297        setting: SettingId,
298        target: SettingTarget,
299    },
300    NotFound {
301        setting: SettingId,
302        target: SettingTarget,
303    },
304    ApplicabilityUnknown {
305        setting: SettingId,
306        target: Box<SettingTarget>,
307    },
308    WrongValueKind {
309        setting: SettingId,
310        expected: &'static str,
311        actual: &'static str,
312        span: Option<crate::core::source::Span>,
313    },
314    InvalidValue {
315        setting: SettingId,
316        message: String,
317        span: Option<crate::core::source::Span>,
318    },
319    SourceUnavailable {
320        setting: SettingId,
321    },
322    SourceMismatch,
323}
324
325impl fmt::Display for SettingOperationError {
326    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
327        match self {
328            Self::NotApplicable { setting, target } => {
329                write!(
330                    formatter,
331                    "setting {setting} does not apply to target {target:?}"
332                )
333            }
334            Self::NotFound { setting, target } => {
335                write!(
336                    formatter,
337                    "setting {setting} was not found for target {target:?}"
338                )
339            }
340            Self::ApplicabilityUnknown { setting, target } => write!(
341                formatter,
342                "applicability of setting {setting} is unknown for target {target:?}"
343            ),
344            Self::WrongValueKind {
345                setting,
346                expected,
347                actual,
348                ..
349            } => write!(
350                formatter,
351                "setting {setting} expects {expected} value, got {actual}"
352            ),
353            Self::InvalidValue {
354                setting, message, ..
355            } => write!(formatter, "invalid value for setting {setting}: {message}"),
356            Self::SourceUnavailable { setting } => {
357                write!(formatter, "setting {setting} has no editable source")
358            }
359            Self::SourceMismatch => formatter.write_str("source no longer matches the edit"),
360        }
361    }
362}
363
364impl std::error::Error for SettingOperationError {}
365
366impl SettingValueDomain {
367    /// Apply source-backed effective clamping without changing the authored
368    /// value held by [`super::SettingsNode`].
369    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
370        match self {
371            Self::Number(bounds) | Self::Percent(bounds) => bounds.effective(authored),
372            _ => None,
373        }
374    }
375}
376
377/// Locale-facing names associated with a canonical setting concept.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct SettingPresentation {
380    pub english_name: &'static str,
381    pub locale_section: &'static str,
382}
383
384impl SettingPresentation {
385    pub fn localized_name(&self, locale: &str) -> Option<&'static str> {
386        if locale.eq_ignore_ascii_case("en-US") {
387            Some(self.english_name)
388        } else {
389            table::localized_name(locale, self.locale_section, self.english_name)
390        }
391    }
392}
393
394/// Source metadata shared by the reviewed table projection.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396#[non_exhaustive]
397pub struct SettingSource {
398    pub kind: SettingSourceKind,
399    pub source: &'static str,
400    pub reviewed: bool,
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum SettingSourceKind {
405    RawWorkshopFixture,
406    WorkshopDataExport,
407}
408
409/// One canonical semantic definition projected from an existing table entry.
410#[derive(Debug, Clone, PartialEq)]
411pub struct SettingDefinition {
412    identity: SettingIdentity,
413    scope: SettingScope,
414    path: String,
415    path_parts: &'static [PathPart<'static>],
416    key: &'static str,
417    target: TargetPattern,
418    domain: SettingValueDomain,
419    enum_domain: Option<&'static str>,
420    presentation: SettingPresentation,
421    source: SettingSource,
422}
423
424impl SettingDefinition {
425    pub fn identity(&self) -> &SettingIdentity {
426        &self.identity
427    }
428
429    pub fn id(&self) -> Option<&SettingId> {
430        match &self.identity {
431            SettingIdentity::Known(id) => Some(id),
432            SettingIdentity::Unknown => None,
433        }
434    }
435
436    pub fn scope(&self) -> SettingScope {
437        self.scope
438    }
439
440    pub fn path(&self) -> &str {
441        &self.path
442    }
443
444    pub fn domain(&self) -> &SettingValueDomain {
445        &self.domain
446    }
447
448    /// Enumerate the accepted members when this setting uses an enum value.
449    /// Boolean settings backed by an enum token (such as `Enabled`) expose
450    /// that token here as well.
451    pub fn enum_members(&self) -> impl Iterator<Item = SettingEnumMember> + '_ {
452        self.enum_domain
453            .into_iter()
454            .flat_map(table::enum_members)
455            .map(|member| SettingEnumMember {
456                domain: member.domain,
457                id: member.member,
458                english_name: member.name,
459            })
460    }
461
462    pub fn target_kind(&self) -> SettingTargetKind {
463        match &self.target {
464            TargetPattern::Global => SettingTargetKind::Global,
465            TargetPattern::Mode(_) => SettingTargetKind::Mode,
466            TargetPattern::Team(_) => SettingTargetKind::Team,
467            TargetPattern::TeamAbility { slot, variant, .. } => SettingTargetKind::TeamAbility {
468                slot: slot.clone(),
469                variant: variant.clone(),
470            },
471            TargetPattern::Hero { .. } => SettingTargetKind::Hero,
472            TargetPattern::HeroAbility { slot, variant, .. } => SettingTargetKind::HeroAbility {
473                slot: slot.clone(),
474                variant: variant.clone(),
475            },
476            TargetPattern::Unknown => SettingTargetKind::Unknown,
477        }
478    }
479
480    pub fn presentation(&self) -> &SettingPresentation {
481        &self.presentation
482    }
483
484    pub fn localized_name(
485        &self,
486        locale: &str,
487        target: &SettingTarget,
488    ) -> Result<Option<&'static str>, GameplayDataError> {
489        match target {
490            SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
491                if self.applicability(target)? == Applicability::NotApplicable {
492                    Ok(None)
493                } else {
494                    Ok(table::hero_setting_name(hero.as_str(), self.key, locale)
495                        .or_else(|| self.presentation.localized_name(locale)))
496                }
497            }
498            _ => Ok(self.presentation.localized_name(locale)),
499        }
500    }
501
502    pub fn source(&self) -> SettingSource {
503        self.source
504    }
505
506    /// Query effective applicability without exposing table deduplication.
507    pub fn applicability(
508        &self,
509        target: &SettingTarget,
510    ) -> Result<Applicability, GameplayDataError> {
511        Ok(match (&self.target, target) {
512            (TargetPattern::Global, SettingTarget::Global) => Applicability::Applicable,
513            (TargetPattern::Mode(expected), SettingTarget::Mode(actual)) => {
514                if expected
515                    .as_deref()
516                    .is_none_or(|expected| expected == actual)
517                {
518                    Applicability::Applicable
519                } else {
520                    Applicability::NotApplicable
521                }
522            }
523            (TargetPattern::Team(expected), SettingTarget::Team(actual)) => {
524                if expected
525                    .as_deref()
526                    .is_none_or(|expected| expected == actual.as_str())
527                {
528                    Applicability::Applicable
529                } else {
530                    Applicability::NotApplicable
531                }
532            }
533            (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => {
534                if team_matches(expected.as_deref(), team.as_ref()) {
535                    Applicability::Unknown
536                } else {
537                    Applicability::NotApplicable
538                }
539            }
540            (
541                TargetPattern::TeamAbility {
542                    team,
543                    slot,
544                    variant: expected_variant,
545                },
546                SettingTarget::TeamAbility {
547                    team: actual_team,
548                    slot: actual_slot,
549                    variant: actual_variant,
550                },
551            ) => {
552                if !team_matches(team.as_deref(), actual_team.as_ref())
553                    || slot != actual_slot
554                    || expected_variant
555                        .as_ref()
556                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
557                {
558                    Applicability::NotApplicable
559                } else {
560                    Applicability::Applicable
561                }
562            }
563            (
564                TargetPattern::TeamAbility {
565                    team,
566                    slot,
567                    variant: expected_variant,
568                },
569                SettingTarget::HeroAbility {
570                    team: actual_team,
571                    hero: actual_hero,
572                    slot: actual_slot,
573                    variant: actual_variant,
574                },
575            ) => {
576                if !team_matches(team.as_deref(), actual_team.as_ref())
577                    || slot != actual_slot
578                    || expected_variant
579                        .as_ref()
580                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
581                {
582                    Applicability::NotApplicable
583                } else {
584                    match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? {
585                        Some(true) => Applicability::Unknown,
586                        Some(false) => Applicability::NotApplicable,
587                        None => Applicability::Unknown,
588                    }
589                }
590            }
591            (
592                TargetPattern::Hero { team, hero },
593                SettingTarget::Hero {
594                    team: actual_team,
595                    hero: actual_hero,
596                },
597            ) => {
598                if !team_matches(team.as_deref(), actual_team.as_ref())
599                    || hero
600                        .as_deref()
601                        .is_some_and(|expected| expected != actual_hero.as_str())
602                {
603                    Applicability::NotApplicable
604                } else {
605                    Applicability::Unknown
606                }
607            }
608            (
609                TargetPattern::HeroAbility {
610                    team,
611                    hero,
612                    slot,
613                    variant: expected_variant,
614                },
615                SettingTarget::HeroAbility {
616                    team: actual_team,
617                    hero: actual_hero,
618                    slot: actual_slot,
619                    ..
620                },
621            ) => {
622                if !team_matches(team.as_deref(), actual_team.as_ref())
623                    || hero
624                        .as_deref()
625                        .is_some_and(|expected| expected != actual_hero.as_str())
626                    || slot.as_str() != actual_slot.as_str()
627                    || expected_variant
628                        .as_ref()
629                        .is_some_and(|expected| Some(expected) != target_variant(target))
630                {
631                    return Ok(Applicability::NotApplicable);
632                }
633                match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? {
634                    None => Applicability::Unknown,
635                    Some(false) => Applicability::NotApplicable,
636                    Some(true) => Applicability::Unknown,
637                }
638            }
639            (TargetPattern::Unknown, _) => Applicability::Unknown,
640            _ => Applicability::NotApplicable,
641        })
642    }
643
644    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
645        self.domain.effective_number(authored)
646    }
647
648    /// Read an existing source-preserving occurrence with its authored value
649    /// and, when source-backed, its effective numeric value.
650    pub fn read(
651        &self,
652        settings: &Settings,
653        target: &SettingTarget,
654    ) -> Result<SettingOccurrence, SettingOperationError> {
655        let id = self.operation_id()?;
656        self.ensure_read_target(target)?;
657        let path = self.concrete_path(target);
658        let node = find_node(&settings.children, &path).ok_or_else(|| {
659            SettingOperationError::NotFound {
660                setting: id.clone(),
661                target: target.clone(),
662            }
663        })?;
664        let authored = value_from_node(node, &self.domain, &id)?;
665        let effective = match authored {
666            SettingValue::Number(value) | SettingValue::Percent(value) => {
667                self.effective_number(value)
668            }
669            _ => None,
670        };
671        Ok(SettingOccurrence {
672            authored,
673            effective,
674        })
675    }
676
677    /// Update one existing occurrence without rebuilding the surrounding
678    /// settings tree. Unknown and unrelated source structure is untouched.
679    pub fn write(
680        &self,
681        settings: &mut Settings,
682        target: &SettingTarget,
683        value: SettingValue,
684    ) -> Result<(), SettingOperationError> {
685        let id = self.operation_id()?;
686        self.ensure_write_target(target)?;
687        let path = self.concrete_path(target);
688        let node = find_node_mut(&mut settings.children, &path).ok_or_else(|| {
689            SettingOperationError::NotFound {
690                setting: id.clone(),
691                target: target.clone(),
692            }
693        })?;
694        let span = node.span();
695        validate_value(&self.domain, &id, &value, span)?;
696        apply_value(node, &id, value)
697    }
698
699    /// Build a source-text edit for one existing scalar occurrence.
700    ///
701    /// The caller retains the original source and applies the returned edit
702    /// while its targeted bytes are unchanged. Comments, whitespace, and every
703    /// other byte remain outside the edit range and are therefore preserved
704    /// without assigning them comment/trivia ownership semantics.
705    pub fn source_edit(
706        &self,
707        source: &str,
708        settings: &Settings,
709        locale: &str,
710        target: &SettingTarget,
711        value: SettingValue,
712    ) -> Result<SettingSourceEdit, SettingOperationError> {
713        let id = self.operation_id()?;
714        self.ensure_write_target(target)?;
715        let path = self.concrete_path(target);
716        let node = find_node(&settings.children, &path).ok_or_else(|| {
717            SettingOperationError::NotFound {
718                setting: id.clone(),
719                target: target.clone(),
720            }
721        })?;
722        validate_value(&self.domain, &id, &value, node.span())?;
723        let span = node
724            .span()
725            .ok_or_else(|| SettingOperationError::SourceUnavailable {
726                setting: id.clone(),
727            })?;
728        let range = source_value_range(source, span, &id)?;
729        let kind = table::lookup(self.path_parts)
730            .expect("settings definition must retain its table entry")
731            .kind;
732        let replacement = source_value_spelling(&self.domain, kind, locale, &id, value)?;
733        Ok(SettingSourceEdit {
734            expected: source[range.clone()].to_string(),
735            range,
736            replacement,
737        })
738    }
739
740    fn ensure_read_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
741        let id = self.operation_id()?;
742        match self
743            .applicability(target)
744            .map_err(|error| SettingOperationError::InvalidValue {
745                setting: id.clone(),
746                message: error.to_string(),
747                span: None,
748            })? {
749            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
750                setting: id,
751                target: target.clone(),
752            }),
753            Applicability::Applicable | Applicability::Unknown => Ok(()),
754        }
755    }
756
757    fn ensure_write_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
758        let id = self.operation_id()?;
759        match self
760            .applicability(target)
761            .map_err(|error| SettingOperationError::InvalidValue {
762                setting: id.clone(),
763                message: error.to_string(),
764                span: None,
765            })? {
766            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
767                setting: id,
768                target: target.clone(),
769            }),
770            Applicability::Unknown => Err(SettingOperationError::ApplicabilityUnknown {
771                setting: id,
772                target: Box::new(target.clone()),
773            }),
774            Applicability::Applicable => Ok(()),
775        }
776    }
777
778    fn operation_id(&self) -> Result<SettingId, SettingOperationError> {
779        self.id()
780            .cloned()
781            .ok_or_else(|| SettingOperationError::InvalidValue {
782                setting: SettingId::new("unknown"),
783                message: "setting has no reviewed canonical identity".to_string(),
784                span: None,
785            })
786    }
787
788    fn concrete_path(&self, target: &SettingTarget) -> Vec<String> {
789        self.path_parts
790            .iter()
791            .map(|part| match part {
792                PathPart::Part(name) => (*name).to_string(),
793                PathPart::Team => target_team(target),
794                PathPart::Hero => target_hero(target),
795            })
796            .collect()
797    }
798}
799
800fn target_team(target: &SettingTarget) -> String {
801    match target {
802        SettingTarget::Team(team)
803        | SettingTarget::Hero {
804            team: Some(team), ..
805        }
806        | SettingTarget::TeamAbility {
807            team: Some(team), ..
808        }
809        | SettingTarget::HeroAbility {
810            team: Some(team), ..
811        } => team.as_str().to_string(),
812        _ => "allTeams".to_string(),
813    }
814}
815
816fn target_hero(target: &SettingTarget) -> String {
817    match target {
818        SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
819            hero.as_str().to_string()
820        }
821        _ => String::new(),
822    }
823}
824
825fn source_value_range(
826    source: &str,
827    span: crate::core::source::Span,
828    setting: &SettingId,
829) -> Result<Range<usize>, SettingOperationError> {
830    let start = byte_offset(source, span.start).ok_or_else(|| {
831        SettingOperationError::SourceUnavailable {
832            setting: setting.clone(),
833        }
834    })?;
835    let end =
836        byte_offset(source, span.end).ok_or_else(|| SettingOperationError::SourceUnavailable {
837            setting: setting.clone(),
838        })?;
839    let member =
840        source
841            .get(start..end)
842            .ok_or_else(|| SettingOperationError::SourceUnavailable {
843                setting: setting.clone(),
844            })?;
845    let Some(colon) = member.find(':') else {
846        return Err(SettingOperationError::SourceUnavailable {
847            setting: setting.clone(),
848        });
849    };
850    let value_start = start + colon + 1;
851    let leading = source[value_start..end].len()
852        - source[value_start..end]
853            .trim_start_matches(char::is_whitespace)
854            .len();
855    let range = value_start + leading..end;
856    if range.is_empty() || source.get(range.clone()).is_none() {
857        return Err(SettingOperationError::SourceUnavailable {
858            setting: setting.clone(),
859        });
860    }
861    Ok(range)
862}
863
864fn byte_offset(source: &str, position: crate::core::source::Position) -> Option<usize> {
865    if !position.is_valid() {
866        return None;
867    }
868    let mut line = 1;
869    let mut col = 1;
870    for (index, character) in source.char_indices() {
871        if line == position.line && col == position.col {
872            return Some(index);
873        }
874        if character == '\n' {
875            line += 1;
876            col = 1;
877        } else {
878            col += 1;
879        }
880    }
881    (line == position.line && col == position.col).then_some(source.len())
882}
883
884fn source_value_spelling(
885    domain: &SettingValueDomain,
886    kind: KeyKind,
887    locale: &str,
888    setting: &SettingId,
889    value: SettingValue,
890) -> Result<String, SettingOperationError> {
891    let localized = |section: &str, english: &str| {
892        if locale.eq_ignore_ascii_case("en-US") {
893            Some(english)
894        } else {
895            table::localized_name(locale, section, english)
896        }
897        .map(str::to_string)
898        .ok_or_else(|| SettingOperationError::InvalidValue {
899            setting: setting.clone(),
900            message: format!("missing {section} locale mapping for '{english}' in {locale}"),
901            span: None,
902        })
903    };
904    match (domain, kind, value) {
905        (SettingValueDomain::Boolean, KeyKind::Bool, SettingValue::Boolean(value)) => {
906            localized("tokens", if value { "On" } else { "Off" })
907        }
908        (SettingValueDomain::Boolean, KeyKind::YesNo, SettingValue::Boolean(value)) => {
909            localized("tokens", if value { "Yes" } else { "No" })
910        }
911        (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => {
912            let english = table::enum_name(domain, "enabled").ok_or_else(|| {
913                SettingOperationError::InvalidValue {
914                    setting: setting.clone(),
915                    message: format!("unknown enabled member for enum domain '{domain}'"),
916                    span: None,
917                }
918            })?;
919            localized("enums", english)
920        }
921        (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
922            Err(SettingOperationError::InvalidValue {
923                setting: setting.clone(),
924                message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
925                span: None,
926            })
927        }
928        (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
929            Ok(crate::format::format_setting_number(value))
930        }
931        (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
932            Ok(format!("{}%", crate::format::format_setting_number(value)))
933        }
934        (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
935            "\"{}\"",
936            crate::output::emitter::escape_settings_string(&value)
937        )),
938        (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
939            let english = table::enum_name(domain, &member).ok_or_else(|| {
940                SettingOperationError::InvalidValue {
941                    setting: setting.clone(),
942                    message: format!("unknown member '{member}' for enum domain '{domain}'"),
943                    span: None,
944                }
945            })?;
946            localized("enums", english)
947        }
948        _ => Err(SettingOperationError::SourceUnavailable {
949            setting: setting.clone(),
950        }),
951    }
952}
953
954fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
955    let (name, rest) = path.split_first()?;
956    let node = children.iter().find(|node| node.name() == name)?;
957    if rest.is_empty() {
958        Some(node)
959    } else {
960        match node {
961            SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
962                find_node(children, rest)
963            }
964            _ => None,
965        }
966    }
967}
968
969fn find_node_mut<'a>(
970    children: &'a mut [SettingsNode],
971    path: &[String],
972) -> Option<&'a mut SettingsNode> {
973    let (name, rest) = path.split_first()?;
974    let node = children.iter_mut().find(|node| node.name() == name)?;
975    if rest.is_empty() {
976        Some(node)
977    } else {
978        match node {
979            SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
980                find_node_mut(children, rest)
981            }
982            _ => None,
983        }
984    }
985}
986
987fn value_kind(value: &SettingValue) -> &'static str {
988    match value {
989        SettingValue::Boolean(_) => "boolean",
990        SettingValue::Number(_) => "number",
991        SettingValue::Percent(_) => "percent",
992        SettingValue::String(_) => "string",
993        SettingValue::Enum(_) => "enum",
994        SettingValue::HeroList(_) => "hero-list",
995        SettingValue::MapList(_) => "map-list",
996        SettingValue::PresenceOnly => "presence-only",
997    }
998}
999
1000fn domain_kind(domain: &SettingValueDomain) -> &'static str {
1001    match domain {
1002        SettingValueDomain::Boolean => "boolean",
1003        SettingValueDomain::Number(_) => "number",
1004        SettingValueDomain::Percent(_) => "percent",
1005        SettingValueDomain::String => "string",
1006        SettingValueDomain::Enum { .. } => "enum",
1007        SettingValueDomain::HeroList => "hero-list",
1008        SettingValueDomain::MapList => "map-list",
1009        SettingValueDomain::PresenceOnly => "presence-only",
1010    }
1011}
1012
1013fn validate_value(
1014    domain: &SettingValueDomain,
1015    id: &SettingId,
1016    value: &SettingValue,
1017    span: Option<crate::core::source::Span>,
1018) -> Result<(), SettingOperationError> {
1019    let expected = domain_kind(domain);
1020    if value_kind(value) != expected {
1021        return Err(SettingOperationError::WrongValueKind {
1022            setting: id.clone(),
1023            expected,
1024            actual: value_kind(value),
1025            span,
1026        });
1027    }
1028    match (domain, value) {
1029        (
1030            SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
1031            SettingValue::Number(value) | SettingValue::Percent(value),
1032        ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
1033            setting: id.clone(),
1034            message: "numeric settings values must be finite".to_string(),
1035            span,
1036        }),
1037        (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
1038            if table::enum_name(domain, member).is_none() =>
1039        {
1040            Err(SettingOperationError::InvalidValue {
1041                setting: id.clone(),
1042                message: format!("unknown member '{member}' for enum domain '{domain}'"),
1043                span,
1044            })
1045        }
1046        (SettingValueDomain::HeroList, SettingValue::HeroList(values))
1047            if values.iter().any(|value| table::hero_name(value).is_none()) =>
1048        {
1049            Err(SettingOperationError::InvalidValue {
1050                setting: id.clone(),
1051                message: "hero list contains an unknown hero".to_string(),
1052                span,
1053            })
1054        }
1055        (SettingValueDomain::MapList, SettingValue::MapList(values))
1056            if values.iter().any(|value| table::map_name(value).is_none()) =>
1057        {
1058            Err(SettingOperationError::InvalidValue {
1059                setting: id.clone(),
1060                message: "map list contains an unknown map".to_string(),
1061                span,
1062            })
1063        }
1064        _ => Ok(()),
1065    }
1066}
1067
1068fn value_from_node(
1069    node: &SettingsNode,
1070    domain: &SettingValueDomain,
1071    id: &SettingId,
1072) -> Result<SettingValue, SettingOperationError> {
1073    let value = match node {
1074        SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
1075        SettingsNode::Number { value, .. } => match domain {
1076            SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
1077            _ => SettingValue::Number(*value),
1078        },
1079        SettingsNode::String { value, .. } => match domain {
1080            SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
1081            _ => SettingValue::String(value.clone()),
1082        },
1083        SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
1084        SettingsNode::List { elements, .. } => {
1085            let values = elements
1086                .iter()
1087                .map(|element| element.value.clone())
1088                .collect();
1089            match domain {
1090                SettingValueDomain::HeroList => SettingValue::HeroList(values),
1091                _ => SettingValue::MapList(values),
1092            }
1093        }
1094        _ => {
1095            return Err(SettingOperationError::InvalidValue {
1096                setting: id.clone(),
1097                message: "settings occurrence is not a typed leaf".to_string(),
1098                span: node.span(),
1099            });
1100        }
1101    };
1102    validate_value(domain, id, &value, node.span())?;
1103    Ok(value)
1104}
1105
1106fn apply_value(
1107    node: &mut SettingsNode,
1108    id: &SettingId,
1109    value: SettingValue,
1110) -> Result<(), SettingOperationError> {
1111    match (node, value) {
1112        (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
1113            *current = value
1114        }
1115        (
1116            SettingsNode::Number { value: current, .. },
1117            SettingValue::Number(value) | SettingValue::Percent(value),
1118        ) => *current = value,
1119        (
1120            SettingsNode::String { value: current, .. },
1121            SettingValue::String(value) | SettingValue::Enum(value),
1122        ) => *current = value,
1123        (
1124            SettingsNode::List { elements, span, .. },
1125            SettingValue::HeroList(values) | SettingValue::MapList(values),
1126        ) => {
1127            if elements.len() != values.len() {
1128                return Err(SettingOperationError::InvalidValue {
1129                    setting: id.clone(),
1130                    message: "source-preserving list edits cannot change list length".to_string(),
1131                    span: *span,
1132                });
1133            }
1134            elements
1135                .iter_mut()
1136                .zip(values)
1137                .for_each(|(element, value)| element.value = value);
1138        }
1139        (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
1140        (node, value) => {
1141            return Err(SettingOperationError::WrongValueKind {
1142                setting: id.clone(),
1143                expected: "existing typed value",
1144                actual: value_kind(&value),
1145                span: node.span(),
1146            });
1147        }
1148    }
1149    Ok(())
1150}
1151
1152#[derive(Debug, Clone, PartialEq)]
1153enum TargetPattern {
1154    Global,
1155    Mode(Option<String>),
1156    Team(Option<String>),
1157    TeamAbility {
1158        team: Option<String>,
1159        slot: LogicalSlot,
1160        variant: Option<AbilityVariant>,
1161    },
1162    Hero {
1163        team: Option<String>,
1164        hero: Option<String>,
1165    },
1166    HeroAbility {
1167        team: Option<String>,
1168        hero: Option<String>,
1169        slot: LogicalSlot,
1170        variant: Option<AbilityVariant>,
1171    },
1172    Unknown,
1173}
1174
1175fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
1176    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
1177}
1178
1179fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
1180    match target {
1181        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
1182        _ => None,
1183    }
1184}
1185
1186fn hero_ability_exists(
1187    hero: &HeroId,
1188    slot: &LogicalSlot,
1189    variant: Option<&AbilityVariant>,
1190) -> Result<Option<bool>, GameplayDataError> {
1191    data::builtin_ref().map_err(Clone::clone).map(|catalog| {
1192        catalog.hero(hero).map(|hero| match variant {
1193            Some(variant) => hero.ability_variant(slot, variant).is_ok(),
1194            None => !hero.abilities_in_slot(slot).is_empty(),
1195        })
1196    })
1197}
1198
1199/// Project all currently reviewed table entries into the canonical semantic
1200/// catalog. The table remains the single parser/emitter source; this
1201/// projection supplies the stable semantic identity and typed facts consumed
1202/// by callers.
1203pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
1204    table::entries().map(SettingDefinition::from_entry)
1205}
1206
1207/// Project one reviewed table entry into the canonical semantic definition.
1208pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
1209    table::lookup(path).map(SettingDefinition::from_entry)
1210}
1211
1212/// Find all definitions for a canonical concept identity.
1213///
1214/// A concept can intentionally have more than one target shape, so the
1215/// result is an iterator rather than a single definition. This keeps normal
1216/// consumers independent of the private table paths while retaining the
1217/// target-specific schema facts.
1218pub fn definitions_by_id(id: &SettingId) -> impl Iterator<Item = SettingDefinition> {
1219    definitions().filter(move |definition| definition.id() == Some(id))
1220}
1221
1222impl SettingDefinition {
1223    fn from_entry(entry: &TableEntry) -> Self {
1224        let scope = scope_for(entry.path);
1225        let key = entry
1226            .path
1227            .last()
1228            .and_then(|part| match part {
1229                PathPart::Part(key) => Some(*key),
1230                _ => None,
1231            })
1232            .unwrap_or("");
1233        let target = target_for(entry.path);
1234        let path = table::path_string(entry.path);
1235        let domain = domain_for(entry.kind);
1236        let identity = canonical_id(scope, key, entry.path)
1237            .map(SettingIdentity::Known)
1238            .unwrap_or(SettingIdentity::Unknown);
1239        Self {
1240            identity,
1241            scope,
1242            path,
1243            path_parts: entry.path,
1244            key,
1245            target,
1246            domain,
1247            enum_domain: match entry.kind {
1248                KeyKind::BoolEnum(domain) | KeyKind::Enum(domain) => Some(domain),
1249                _ => None,
1250            },
1251            presentation: SettingPresentation {
1252                english_name: entry.workshop_name,
1253                locale_section: "labels",
1254            },
1255            source: SettingSource {
1256                kind: if table::is_generated_entry(entry) {
1257                    SettingSourceKind::WorkshopDataExport
1258                } else {
1259                    SettingSourceKind::RawWorkshopFixture
1260                },
1261                source: if table::is_generated_entry(entry) {
1262                    "workshop-data/workshop-data.json"
1263                } else {
1264                    "pinned raw Workshop settings fixtures"
1265                },
1266                reviewed: true,
1267            },
1268        }
1269    }
1270}
1271
1272fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
1273    match path.first() {
1274        Some(PathPart::Part("main")) => SettingScope::Main,
1275        Some(PathPart::Part("lobby")) => SettingScope::Lobby,
1276        Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
1277        Some(PathPart::Part("heroes")) => SettingScope::Heroes,
1278        Some(PathPart::Part("extensions")) => SettingScope::Extensions,
1279        Some(PathPart::Part("workshop")) => SettingScope::Workshop,
1280        _ => SettingScope::Unknown,
1281    }
1282}
1283
1284fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
1285    match path {
1286        [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
1287        [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
1288            TargetPattern::Mode(Some((*mode).to_string()))
1289        }
1290        [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
1291        [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
1292            target_for_hero(path, None)
1293        }
1294        [
1295            PathPart::Part("heroes"),
1296            PathPart::Part(team),
1297            PathPart::Hero,
1298            ..,
1299        ] => target_for_hero(path, Some((*team).to_string())),
1300        [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
1301        [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
1302            target_for_team(path, Some((*team).to_string()))
1303        }
1304        [
1305            PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
1306            ..,
1307        ] => TargetPattern::Global,
1308        _ => TargetPattern::Unknown,
1309    }
1310}
1311
1312fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1313    match semantic_ability_slot_for_path(path) {
1314        Some(slot) => TargetPattern::TeamAbility {
1315            team,
1316            slot: LogicalSlot::new(slot),
1317            variant: None,
1318        },
1319        None => TargetPattern::Team(team),
1320    }
1321}
1322
1323fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1324    let slot = semantic_ability_slot_for_path(path).map(str::to_string);
1325    match slot {
1326        Some(slot) => TargetPattern::HeroAbility {
1327            team,
1328            hero: None,
1329            slot: LogicalSlot::new(slot),
1330            variant: None,
1331        },
1332        None => TargetPattern::Hero { team, hero: None },
1333    }
1334}
1335
1336fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
1337    match path.last() {
1338        Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
1339        Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
1340        Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
1341        Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
1342        Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
1343        Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
1344        Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
1345        Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
1346        _ => table::ability_slot_for_path(path),
1347    }
1348}
1349
1350fn domain_for(kind: KeyKind) -> SettingValueDomain {
1351    match kind {
1352        KeyKind::Flag => SettingValueDomain::PresenceOnly,
1353        KeyKind::String => SettingValueDomain::String,
1354        KeyKind::Bool | KeyKind::YesNo | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean,
1355        KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
1356        KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
1357        KeyKind::Enum(domain) => SettingValueDomain::Enum {
1358            domain: domain.to_string(),
1359        },
1360        KeyKind::ListMap => SettingValueDomain::MapList,
1361        KeyKind::ListHero => SettingValueDomain::HeroList,
1362    }
1363}
1364
1365fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
1366    let prefix = match scope {
1367        SettingScope::Main => "main",
1368        SettingScope::Lobby => "lobby",
1369        SettingScope::GameModes => "gameMode",
1370        SettingScope::Heroes => "hero",
1371        SettingScope::Extensions => "extension",
1372        SettingScope::Workshop => "workshop",
1373        SettingScope::Unknown => "unknown",
1374    };
1375    if matches!(scope, SettingScope::Unknown) {
1376        return None;
1377    }
1378    let concept = canonical_concept(key, path)?;
1379    Some(SettingId::new(format!("setting.{prefix}.{concept}")))
1380}
1381
1382/// Map a Workshop leaf to a locale-independent setting concept. These names
1383/// intentionally describe the setting's meaning, while hero and logical slot
1384/// topology stays in `SettingTarget`.
1385fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option<String> {
1386    let key = key.trim_end_matches('%');
1387    Some(match key {
1388        "health" => "health".to_string(),
1389        "damageDealt" | "damageReceived" | "healingDealt" | "healingReceived" => key.to_string(),
1390        "passiveUltGen" => "ultimateGeneration.passive".to_string(),
1391        "combatUltGen" => "ultimateGeneration.combat".to_string(),
1392        "ultGen" => "ultimateGeneration".to_string(),
1393        "enableUlt" => "ability.enabled".to_string(),
1394        "enablePrimaryFire"
1395        | "enableSecondaryFire"
1396        | "enableGenericSecondaryFire"
1397        | "enableAbility1"
1398        | "enableAbility2"
1399        | "enableAbility3" => "ability.enabled".to_string(),
1400        "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(),
1401        "enableScoping" => "primaryFire.scopingEnabled".to_string(),
1402        "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(),
1403        "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(),
1404        "setValidControlPoints" | "firstActiveControlPoint" => path
1405            .iter()
1406            .filter_map(|part| match part {
1407                PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name),
1408                _ => None,
1409            })
1410            .next()
1411            .map(|mode| format!("{key}.{mode}"))?,
1412        _ => key.to_string(),
1413    })
1414}
1415
1416/// Validate the effective settings catalog and reject stale or conflicting
1417/// semantic projections before parser/emitter data is shipped.
1418pub fn validate_catalog() -> Result<(), Vec<String>> {
1419    use std::collections::{HashMap, HashSet};
1420
1421    let mut errors = Vec::new();
1422    errors.extend(reconciliation::validate());
1423    errors.extend(validate_raw_projection(table::raw_entries()));
1424    errors.extend(validate_enum_projection(
1425        table::ENUM_MEMBERS.iter(),
1426        table::GENERATED_ENUM_MEMBERS.iter(),
1427        &reconciliation::data().enum_member_mappings,
1428    ));
1429    let mut paths = HashSet::new();
1430    let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> =
1431        HashMap::new();
1432    let mut concept_keys: HashMap<(String, SettingTargetKind), String> = HashMap::new();
1433
1434    for definition in definitions() {
1435        if !paths.insert(definition.path.clone()) {
1436            errors.push(format!("duplicate settings path: {}", definition.path));
1437        }
1438        if definition.scope == SettingScope::Unknown {
1439            errors.push(format!("unknown settings scope: {}", definition.path));
1440        }
1441        let Some(id) = definition.id() else {
1442            errors.push(format!(
1443                "missing canonical settings identity: {}",
1444                definition.path
1445            ));
1446            continue;
1447        };
1448        if !definition.source.reviewed {
1449            errors.push(format!(
1450                "unreviewed settings definition: {}",
1451                definition.path
1452            ));
1453        }
1454        if definition.presentation.english_name.is_empty() {
1455            errors.push(format!(
1456                "missing settings presentation: {}",
1457                definition.path
1458            ));
1459        }
1460        let target_kind = definition.target_kind();
1461        let semantic_key = semantic_identity_key(definition.key);
1462        let collision_key = (id.as_str().to_string(), target_kind.clone());
1463        if let Some(previous_key) = concept_keys.insert(collision_key, semantic_key.clone()) {
1464            if previous_key != semantic_key {
1465                errors.push(format!(
1466                    "conflicting settings concepts for {id}: {previous_key} vs {semantic_key}"
1467                ));
1468            }
1469        }
1470        let key = (id.as_str().to_string(), target_kind, semantic_key);
1471        if let Some(previous) = concepts.insert(key, definition.domain.clone()) {
1472            if previous != definition.domain {
1473                errors.push(format!("conflicting settings domains for {id}"));
1474            }
1475        }
1476    }
1477    if errors.is_empty() {
1478        Ok(())
1479    } else {
1480        Err(errors)
1481    }
1482}
1483
1484/// Reject raw table overlaps unless their complete parser/emitter contract is
1485/// identical. Effective lookup may deduplicate exact repeats, but must never
1486/// make a divergent generated or fixture projection silently win.
1487fn validate_raw_projection(
1488    entries: impl IntoIterator<Item = table::ProjectedEntry>,
1489) -> Vec<String> {
1490    use std::collections::HashMap;
1491
1492    let mut errors = Vec::new();
1493    let mut paths = HashMap::new();
1494    for projected in entries {
1495        let entry = projected.entry;
1496        if let Some(previous) = paths.insert(entry.path, projected) {
1497            if previous.entry != entry
1498                && !reconciled_entry_override(
1499                    table::path_string(entry.path).as_str(),
1500                    previous,
1501                    projected,
1502                )
1503            {
1504                errors.push(format!(
1505                    "conflicting duplicate settings path between {} and {}: {}",
1506                    previous.source.label(),
1507                    projected.source.label(),
1508                    table::path_string(entry.path),
1509                ));
1510            }
1511        }
1512    }
1513    errors
1514}
1515
1516fn reconciled_entry_override(
1517    path: &str,
1518    fixture: table::ProjectedEntry,
1519    generated: table::ProjectedEntry,
1520) -> bool {
1521    use table::ProjectionSource::{FixtureTable, WorkshopDataExport};
1522
1523    let (fixture, generated) = match (fixture.source, generated.source) {
1524        (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry),
1525        (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry),
1526        _ => return false,
1527    };
1528    reconciliation::data()
1529        .entry_overrides
1530        .iter()
1531        .find(|override_| override_.path == path)
1532        .is_some_and(|override_| {
1533            entry_contract_matches(fixture, &override_.fixture)
1534                && entry_contract_matches(generated, &override_.generated)
1535        })
1536}
1537
1538fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool {
1539    entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected)
1540}
1541
1542fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool {
1543    match (kind, expected.kind.as_str(), expected.domain.as_deref()) {
1544        (KeyKind::Flag, "flag", None)
1545        | (KeyKind::String, "string", None)
1546        | (KeyKind::Bool, "bool", None)
1547        | (KeyKind::YesNo, "yesNo", None)
1548        | (KeyKind::Number, "number", None)
1549        | (KeyKind::Percent, "percent", None)
1550        | (KeyKind::ListMap, "mapList", None)
1551        | (KeyKind::ListHero, "heroList", None) => true,
1552        (KeyKind::BoolEnum(actual), "boolEnum", Some(expected)) => actual == expected,
1553        (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected,
1554        _ => false,
1555    }
1556}
1557
1558/// Validate enum members independently of entry lookup order. This catches
1559/// both stale enum projections and conflicting duplicate spellings that the
1560/// lookup helper would otherwise hide.
1561fn validate_enum_projection(
1562    fixture_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1563    generated_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1564    mappings: &[reconciliation::EnumMemberMapping],
1565) -> Vec<String> {
1566    use std::collections::{HashMap, HashSet};
1567
1568    let domains: HashSet<_> = table::entries()
1569        .filter_map(|entry| match entry.kind {
1570            KeyKind::Enum(domain) | KeyKind::BoolEnum(domain) => Some(domain),
1571            _ => None,
1572        })
1573        .collect();
1574    let mut errors = Vec::new();
1575    let mut members = HashMap::new();
1576    let mut names = HashMap::new();
1577    for member in fixture_entries {
1578        if !domains.contains(member.domain) {
1579            errors.push(format!("orphaned settings enum domain: {}", member.domain));
1580        }
1581        let key = (member.domain, member.member);
1582        if let Some(previous) = members.insert(key, member.name) {
1583            if previous != member.name {
1584                errors.push(format!(
1585                    "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1586                    member.domain, member.member, member.name
1587                ));
1588            }
1589        }
1590        if let Some(previous) = names.insert((member.domain, member.name), member.member) {
1591            if previous != member.member {
1592                errors.push(format!(
1593                    "conflicting settings enum display name {}.{:?}: {previous} vs {}",
1594                    member.domain, member.name, member.member
1595                ));
1596            }
1597        }
1598    }
1599    let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS
1600        .iter()
1601        .map(|member| ((member.domain, member.member), member))
1602        .collect();
1603    let mut mapped_sources = HashSet::new();
1604    for member in generated_entries {
1605        let key = (member.domain, member.member);
1606        if let Some(previous) = members.insert(key, member.name) {
1607            if previous != member.name {
1608                errors.push(format!(
1609                    "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1610                    member.domain, member.member, member.name
1611                ));
1612            }
1613        }
1614        let mapping = mappings.iter().find(|mapping| {
1615            mapping.source_domain == member.domain && mapping.source_member == member.member
1616        });
1617        if mapping.is_none() && !domains.contains(member.domain) {
1618            errors.push(format!("orphaned settings enum domain: {}", member.domain));
1619        }
1620        let (domain, canonical_member, name) = match mapping {
1621            Some(mapping) => {
1622                if !mapped_sources.insert((
1623                    mapping.source_domain.as_str(),
1624                    mapping.source_member.as_str(),
1625                )) {
1626                    errors.push(format!(
1627                        "duplicate settings enum reconciliation for {}.{}",
1628                        mapping.source_domain, mapping.source_member
1629                    ));
1630                }
1631                match fixture_members.get(&(
1632                    mapping.target_domain.as_str(),
1633                    mapping.target_member.as_str(),
1634                )) {
1635                    Some(target) if target.name == member.name => {
1636                        (target.domain, target.member, target.name)
1637                    }
1638                    Some(target) => {
1639                        errors.push(format!(
1640                            "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}",
1641                            mapping.source_domain, mapping.source_member,
1642                            mapping.target_domain, mapping.target_member, member.name, target.name
1643                        ));
1644                        continue;
1645                    }
1646                    None => {
1647                        errors.push(format!(
1648                            "settings enum reconciliation target is missing: {}.{} -> {}.{}",
1649                            mapping.source_domain,
1650                            mapping.source_member,
1651                            mapping.target_domain,
1652                            mapping.target_member
1653                        ));
1654                        continue;
1655                    }
1656                }
1657            }
1658            None => (member.domain, member.member, member.name),
1659        };
1660        if let Some(previous) = names.insert((domain, name), canonical_member) {
1661            if previous != canonical_member {
1662                errors.push(format!(
1663                    "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}",
1664                    domain
1665                ));
1666            }
1667        }
1668    }
1669    for mapping in mappings {
1670        if !mapped_sources.contains(&(
1671            mapping.source_domain.as_str(),
1672            mapping.source_member.as_str(),
1673        )) {
1674            errors.push(format!(
1675                "orphaned settings enum reconciliation: {}.{}",
1676                mapping.source_domain, mapping.source_member
1677            ));
1678        }
1679    }
1680    errors
1681}
1682
1683fn semantic_identity_key(key: &str) -> String {
1684    match key {
1685        "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(),
1686        _ => key.to_string(),
1687    }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    use super::*;
1693
1694    static DUPLICATE_PATH: [PathPart<'static>; 2] =
1695        [PathPart::Part("test"), PathPart::Part("value")];
1696    static FIXTURE_ENTRY: TableEntry = TableEntry {
1697        path: &DUPLICATE_PATH,
1698        workshop_name: "Fixture Value",
1699        kind: KeyKind::Bool,
1700    };
1701    static GENERATED_ENTRY: TableEntry = TableEntry {
1702        path: &DUPLICATE_PATH,
1703        workshop_name: "Generated Value",
1704        kind: KeyKind::Bool,
1705    };
1706    static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1707        domain: "mapRotation",
1708        member: "afterAGame",
1709        name: "After A Game",
1710    };
1711    static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1712        domain: "mapRotation",
1713        member: "afterAGame",
1714        name: "After Game",
1715    };
1716    static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember {
1717        domain: "mapRotation",
1718        member: "afterMirrorMatch",
1719        name: "After A Game",
1720    };
1721    static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1722        domain: "setting_lobby_mapRotation",
1723        member: "afterGame",
1724        name: "After A Game",
1725    };
1726
1727    fn definition(target: TargetPattern) -> SettingDefinition {
1728        SettingDefinition {
1729            identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
1730            scope: SettingScope::Heroes,
1731            path: "heroes.test.value".to_string(),
1732            path_parts: &[],
1733            key: "value",
1734            target,
1735            domain: SettingValueDomain::Boolean,
1736            enum_domain: None,
1737            presentation: SettingPresentation {
1738                english_name: "Value",
1739                locale_section: "labels",
1740            },
1741            source: SettingSource {
1742                kind: SettingSourceKind::RawWorkshopFixture,
1743                source: "test",
1744                reviewed: true,
1745            },
1746        }
1747    }
1748
1749    #[test]
1750    fn common_target_narrowing_rejects_team_and_slot_mismatches() {
1751        let team = definition(TargetPattern::Team(Some("team1".to_string())));
1752        assert_eq!(
1753            team.applicability(&SettingTarget::Hero {
1754                team: Some(TeamId::new("team2")),
1755                hero: HeroId::from(crate::gameplay::hero_ids::ANA),
1756            })
1757            .expect("applicability"),
1758            Applicability::NotApplicable
1759        );
1760
1761        let team_ability = definition(TargetPattern::TeamAbility {
1762            team: Some("team1".to_string()),
1763            slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
1764            variant: None,
1765        });
1766        let target = SettingTarget::HeroAbility {
1767            team: Some(TeamId::new("team2")),
1768            hero: HeroId::from(crate::gameplay::hero_ids::DVA),
1769            slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
1770            variant: Some(AbilityVariant::new("mech")),
1771        };
1772        assert_eq!(
1773            team_ability.applicability(&target).expect("applicability"),
1774            Applicability::NotApplicable
1775        );
1776    }
1777
1778    #[test]
1779    fn raw_projection_conflicts_include_presentation_contract() {
1780        let errors = validate_raw_projection([
1781            table::ProjectedEntry {
1782                source: table::ProjectionSource::FixtureTable,
1783                entry: &FIXTURE_ENTRY,
1784            },
1785            table::ProjectedEntry {
1786                source: table::ProjectionSource::WorkshopDataExport,
1787                entry: &GENERATED_ENTRY,
1788            },
1789        ]);
1790        assert_eq!(errors.len(), 1);
1791        assert!(errors[0].contains("fixture table"));
1792        assert!(errors[0].contains("Workshop-data export"));
1793    }
1794
1795    #[test]
1796    fn enum_projection_conflicts_are_not_hidden_by_lookup_order() {
1797        let errors =
1798            validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]);
1799        assert_eq!(errors.len(), 1);
1800        assert!(errors[0].contains("mapRotation.afterAGame"));
1801    }
1802
1803    #[test]
1804    fn enum_projection_rejects_display_name_to_identity_collisions() {
1805        let errors =
1806            validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]);
1807        assert_eq!(errors.len(), 1);
1808        assert!(errors[0].contains("conflicting settings enum display name"));
1809    }
1810
1811    #[test]
1812    fn enum_projection_reconciles_export_members_to_canonical_identities() {
1813        let mappings = [reconciliation::EnumMemberMapping {
1814            source_domain: "setting_lobby_mapRotation".to_string(),
1815            source_member: "afterGame".to_string(),
1816            target_domain: "mapRotation".to_string(),
1817            target_member: "afterAGame".to_string(),
1818        }];
1819        let errors =
1820            validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings);
1821        assert!(errors.is_empty(), "{errors:?}");
1822    }
1823}