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