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