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::YesNo, SettingValue::Boolean(value)) => {
904            localized("tokens", if value { "Yes" } else { "No" })
905        }
906        (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => {
907            let english = table::enum_name(domain, "enabled").ok_or_else(|| {
908                SettingOperationError::InvalidValue {
909                    setting: setting.clone(),
910                    message: format!("unknown enabled member for enum domain '{domain}'"),
911                    span: None,
912                }
913            })?;
914            localized("enums", english)
915        }
916        (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
917            Err(SettingOperationError::InvalidValue {
918                setting: setting.clone(),
919                message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
920                span: None,
921            })
922        }
923        (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
924            Ok(crate::format::format_setting_number(value))
925        }
926        (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
927            Ok(format!("{}%", crate::format::format_setting_number(value)))
928        }
929        (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
930            "\"{}\"",
931            crate::output::emitter::escape_settings_string(&value)
932        )),
933        (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
934            let english = table::enum_name(domain, &member).ok_or_else(|| {
935                SettingOperationError::InvalidValue {
936                    setting: setting.clone(),
937                    message: format!("unknown member '{member}' for enum domain '{domain}'"),
938                    span: None,
939                }
940            })?;
941            localized("enums", english)
942        }
943        _ => Err(SettingOperationError::SourceUnavailable {
944            setting: setting.clone(),
945        }),
946    }
947}
948
949fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
950    let (name, rest) = path.split_first()?;
951    let node = children.iter().find(|node| node.name() == name)?;
952    if rest.is_empty() {
953        Some(node)
954    } else {
955        match node {
956            SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
957                find_node(children, rest)
958            }
959            _ => None,
960        }
961    }
962}
963
964fn find_node_mut<'a>(
965    children: &'a mut [SettingsNode],
966    path: &[String],
967) -> Option<&'a mut SettingsNode> {
968    let (name, rest) = path.split_first()?;
969    let node = children.iter_mut().find(|node| node.name() == name)?;
970    if rest.is_empty() {
971        Some(node)
972    } else {
973        match node {
974            SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
975                find_node_mut(children, rest)
976            }
977            _ => None,
978        }
979    }
980}
981
982fn value_kind(value: &SettingValue) -> &'static str {
983    match value {
984        SettingValue::Boolean(_) => "boolean",
985        SettingValue::Number(_) => "number",
986        SettingValue::Percent(_) => "percent",
987        SettingValue::String(_) => "string",
988        SettingValue::Enum(_) => "enum",
989        SettingValue::HeroList(_) => "hero-list",
990        SettingValue::MapList(_) => "map-list",
991        SettingValue::PresenceOnly => "presence-only",
992    }
993}
994
995fn domain_kind(domain: &SettingValueDomain) -> &'static str {
996    match domain {
997        SettingValueDomain::Boolean => "boolean",
998        SettingValueDomain::Number(_) => "number",
999        SettingValueDomain::Percent(_) => "percent",
1000        SettingValueDomain::String => "string",
1001        SettingValueDomain::Enum { .. } => "enum",
1002        SettingValueDomain::HeroList => "hero-list",
1003        SettingValueDomain::MapList => "map-list",
1004        SettingValueDomain::PresenceOnly => "presence-only",
1005    }
1006}
1007
1008fn validate_value(
1009    domain: &SettingValueDomain,
1010    id: &SettingId,
1011    value: &SettingValue,
1012    span: Option<crate::core::source::Span>,
1013) -> Result<(), SettingOperationError> {
1014    let expected = domain_kind(domain);
1015    if value_kind(value) != expected {
1016        return Err(SettingOperationError::WrongValueKind {
1017            setting: id.clone(),
1018            expected,
1019            actual: value_kind(value),
1020            span,
1021        });
1022    }
1023    match (domain, value) {
1024        (
1025            SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
1026            SettingValue::Number(value) | SettingValue::Percent(value),
1027        ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
1028            setting: id.clone(),
1029            message: "numeric settings values must be finite".to_string(),
1030            span,
1031        }),
1032        (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
1033            if table::enum_name(domain, member).is_none() =>
1034        {
1035            Err(SettingOperationError::InvalidValue {
1036                setting: id.clone(),
1037                message: format!("unknown member '{member}' for enum domain '{domain}'"),
1038                span,
1039            })
1040        }
1041        (SettingValueDomain::HeroList, SettingValue::HeroList(values))
1042            if values.iter().any(|value| table::hero_name(value).is_none()) =>
1043        {
1044            Err(SettingOperationError::InvalidValue {
1045                setting: id.clone(),
1046                message: "hero list contains an unknown hero".to_string(),
1047                span,
1048            })
1049        }
1050        (SettingValueDomain::MapList, SettingValue::MapList(values))
1051            if values.iter().any(|value| table::map_name(value).is_none()) =>
1052        {
1053            Err(SettingOperationError::InvalidValue {
1054                setting: id.clone(),
1055                message: "map list contains an unknown map".to_string(),
1056                span,
1057            })
1058        }
1059        _ => Ok(()),
1060    }
1061}
1062
1063fn value_from_node(
1064    node: &SettingsNode,
1065    domain: &SettingValueDomain,
1066    id: &SettingId,
1067) -> Result<SettingValue, SettingOperationError> {
1068    let value = match node {
1069        SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
1070        SettingsNode::Number { value, .. } => match domain {
1071            SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
1072            _ => SettingValue::Number(*value),
1073        },
1074        SettingsNode::String { value, .. } => match domain {
1075            SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
1076            _ => SettingValue::String(value.clone()),
1077        },
1078        SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
1079        SettingsNode::List { elements, .. } => {
1080            let values = elements
1081                .iter()
1082                .map(|element| element.value.clone())
1083                .collect();
1084            match domain {
1085                SettingValueDomain::HeroList => SettingValue::HeroList(values),
1086                _ => SettingValue::MapList(values),
1087            }
1088        }
1089        _ => {
1090            return Err(SettingOperationError::InvalidValue {
1091                setting: id.clone(),
1092                message: "settings occurrence is not a typed leaf".to_string(),
1093                span: node.span(),
1094            });
1095        }
1096    };
1097    validate_value(domain, id, &value, node.span())?;
1098    Ok(value)
1099}
1100
1101fn apply_value(
1102    node: &mut SettingsNode,
1103    id: &SettingId,
1104    value: SettingValue,
1105) -> Result<(), SettingOperationError> {
1106    match (node, value) {
1107        (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
1108            *current = value
1109        }
1110        (
1111            SettingsNode::Number { value: current, .. },
1112            SettingValue::Number(value) | SettingValue::Percent(value),
1113        ) => *current = value,
1114        (
1115            SettingsNode::String { value: current, .. },
1116            SettingValue::String(value) | SettingValue::Enum(value),
1117        ) => *current = value,
1118        (
1119            SettingsNode::List { elements, span, .. },
1120            SettingValue::HeroList(values) | SettingValue::MapList(values),
1121        ) => {
1122            if elements.len() != values.len() {
1123                return Err(SettingOperationError::InvalidValue {
1124                    setting: id.clone(),
1125                    message: "source-preserving list edits cannot change list length".to_string(),
1126                    span: *span,
1127                });
1128            }
1129            elements
1130                .iter_mut()
1131                .zip(values)
1132                .for_each(|(element, value)| element.value = value);
1133        }
1134        (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
1135        (node, value) => {
1136            return Err(SettingOperationError::WrongValueKind {
1137                setting: id.clone(),
1138                expected: "existing typed value",
1139                actual: value_kind(&value),
1140                span: node.span(),
1141            });
1142        }
1143    }
1144    Ok(())
1145}
1146
1147#[derive(Debug, Clone, PartialEq)]
1148enum TargetPattern {
1149    Global,
1150    Mode(Option<String>),
1151    Team(Option<String>),
1152    TeamAbility {
1153        team: Option<String>,
1154        slot: LogicalSlot,
1155        variant: Option<AbilityVariant>,
1156    },
1157    Hero {
1158        team: Option<String>,
1159        hero: Option<String>,
1160    },
1161    HeroAbility {
1162        team: Option<String>,
1163        hero: Option<String>,
1164        slot: LogicalSlot,
1165        variant: Option<AbilityVariant>,
1166    },
1167    Unknown,
1168}
1169
1170fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
1171    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
1172}
1173
1174fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
1175    match target {
1176        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
1177        _ => None,
1178    }
1179}
1180
1181fn hero_ability_exists(
1182    hero: &HeroId,
1183    slot: &LogicalSlot,
1184    variant: Option<&AbilityVariant>,
1185) -> Result<Option<bool>, GameplayDataError> {
1186    data::builtin_ref().map_err(Clone::clone).map(|catalog| {
1187        catalog.hero(hero).map(|hero| match variant {
1188            Some(variant) => hero.ability_variant(slot, variant).is_ok(),
1189            None => !hero.abilities_in_slot(slot).is_empty(),
1190        })
1191    })
1192}
1193
1194/// Project all currently reviewed table entries into the canonical semantic
1195/// catalog. The table remains the single parser/emitter source; this
1196/// projection supplies the stable semantic identity and typed facts consumed
1197/// by callers.
1198pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
1199    table::entries().map(SettingDefinition::from_entry)
1200}
1201
1202/// Project one reviewed table entry into the canonical semantic definition.
1203pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
1204    table::lookup(path).map(SettingDefinition::from_entry)
1205}
1206
1207/// Find all definitions for a canonical concept identity.
1208///
1209/// A concept can intentionally have more than one target shape, so the
1210/// result is an iterator rather than a single definition. This keeps normal
1211/// consumers independent of the private table paths while retaining the
1212/// target-specific schema facts.
1213pub fn definitions_by_id(id: &SettingId) -> impl Iterator<Item = SettingDefinition> {
1214    definitions().filter(move |definition| definition.id() == Some(id))
1215}
1216
1217impl SettingDefinition {
1218    fn from_entry(entry: &TableEntry) -> Self {
1219        let scope = scope_for(entry.path);
1220        let key = entry
1221            .path
1222            .last()
1223            .and_then(|part| match part {
1224                PathPart::Part(key) => Some(*key),
1225                _ => None,
1226            })
1227            .unwrap_or("");
1228        let target = target_for(entry.path);
1229        let path = table::path_string(entry.path);
1230        let domain = domain_for(entry.kind);
1231        let identity = canonical_id(scope, key, entry.path)
1232            .map(SettingIdentity::Known)
1233            .unwrap_or(SettingIdentity::Unknown);
1234        Self {
1235            identity,
1236            scope,
1237            path,
1238            path_parts: entry.path,
1239            key,
1240            target,
1241            domain,
1242            enum_domain: match entry.kind {
1243                KeyKind::BoolEnum(domain) | KeyKind::Enum(domain) => Some(domain),
1244                _ => None,
1245            },
1246            presentation: SettingPresentation {
1247                english_name: entry.workshop_name,
1248                locale_section: "labels",
1249            },
1250            source: SettingSource {
1251                kind: if table::is_generated_entry(entry) {
1252                    SettingSourceKind::WorkshopDataExport
1253                } else {
1254                    SettingSourceKind::RawWorkshopFixture
1255                },
1256                source: if table::is_generated_entry(entry) {
1257                    "workshop-data/workshop-data.json"
1258                } else {
1259                    "pinned raw Workshop settings fixtures"
1260                },
1261                reviewed: true,
1262            },
1263        }
1264    }
1265}
1266
1267fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
1268    match path.first() {
1269        Some(PathPart::Part("main")) => SettingScope::Main,
1270        Some(PathPart::Part("lobby")) => SettingScope::Lobby,
1271        Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
1272        Some(PathPart::Part("heroes")) => SettingScope::Heroes,
1273        Some(PathPart::Part("extensions")) => SettingScope::Extensions,
1274        Some(PathPart::Part("workshop")) => SettingScope::Workshop,
1275        _ => SettingScope::Unknown,
1276    }
1277}
1278
1279fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
1280    match path {
1281        [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
1282        [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
1283            TargetPattern::Mode(Some((*mode).to_string()))
1284        }
1285        [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
1286        [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
1287            target_for_hero(path, None)
1288        }
1289        [
1290            PathPart::Part("heroes"),
1291            PathPart::Part(team),
1292            PathPart::Hero,
1293            ..,
1294        ] => target_for_hero(path, Some((*team).to_string())),
1295        [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
1296        [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
1297            target_for_team(path, Some((*team).to_string()))
1298        }
1299        [
1300            PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
1301            ..,
1302        ] => TargetPattern::Global,
1303        _ => TargetPattern::Unknown,
1304    }
1305}
1306
1307fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1308    match semantic_ability_slot_for_path(path) {
1309        Some(slot) => TargetPattern::TeamAbility {
1310            team,
1311            slot: LogicalSlot::new(slot),
1312            variant: None,
1313        },
1314        None => TargetPattern::Team(team),
1315    }
1316}
1317
1318fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1319    let slot = semantic_ability_slot_for_path(path).map(str::to_string);
1320    match slot {
1321        Some(slot) => TargetPattern::HeroAbility {
1322            team,
1323            hero: None,
1324            slot: LogicalSlot::new(slot),
1325            variant: None,
1326        },
1327        None => TargetPattern::Hero { team, hero: None },
1328    }
1329}
1330
1331fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
1332    match path.last() {
1333        Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
1334        Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
1335        Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
1336        Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
1337        Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
1338        Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
1339        Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
1340        Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
1341        _ => table::ability_slot_for_path(path),
1342    }
1343}
1344
1345fn domain_for(kind: KeyKind) -> SettingValueDomain {
1346    match kind {
1347        KeyKind::Flag => SettingValueDomain::PresenceOnly,
1348        KeyKind::String => SettingValueDomain::String,
1349        KeyKind::Bool | KeyKind::YesNo | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean,
1350        KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
1351        KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
1352        KeyKind::Enum(domain) => SettingValueDomain::Enum {
1353            domain: domain.to_string(),
1354        },
1355        KeyKind::ListMap => SettingValueDomain::MapList,
1356        KeyKind::ListHero => SettingValueDomain::HeroList,
1357    }
1358}
1359
1360fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
1361    let prefix = match scope {
1362        SettingScope::Main => "main",
1363        SettingScope::Lobby => "lobby",
1364        SettingScope::GameModes => "gameMode",
1365        SettingScope::Heroes => "hero",
1366        SettingScope::Extensions => "extension",
1367        SettingScope::Workshop => "workshop",
1368        SettingScope::Unknown => "unknown",
1369    };
1370    if matches!(scope, SettingScope::Unknown) {
1371        return None;
1372    }
1373    let concept = canonical_concept(key, path)?;
1374    Some(SettingId::new(format!("setting.{prefix}.{concept}")))
1375}
1376
1377/// Map a Workshop leaf to a locale-independent setting concept. These names
1378/// intentionally describe the setting's meaning, while hero and logical slot
1379/// topology stays in `SettingTarget`.
1380fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option<String> {
1381    let key = key.trim_end_matches('%');
1382    Some(match key {
1383        "health" => "health".to_string(),
1384        "damageDealt" | "damageReceived" | "healingDealt" | "healingReceived" => key.to_string(),
1385        "passiveUltGen" => "ultimateGeneration.passive".to_string(),
1386        "combatUltGen" => "ultimateGeneration.combat".to_string(),
1387        "ultGen" => "ultimateGeneration".to_string(),
1388        "enableUlt" => "ability.enabled".to_string(),
1389        "enablePrimaryFire"
1390        | "enableSecondaryFire"
1391        | "enableGenericSecondaryFire"
1392        | "enableAbility1"
1393        | "enableAbility2"
1394        | "enableAbility3" => "ability.enabled".to_string(),
1395        "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(),
1396        "enableScoping" => "primaryFire.scopingEnabled".to_string(),
1397        "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(),
1398        "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(),
1399        "setValidControlPoints" | "firstActiveControlPoint" => path
1400            .iter()
1401            .filter_map(|part| match part {
1402                PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name),
1403                _ => None,
1404            })
1405            .next()
1406            .map(|mode| format!("{key}.{mode}"))?,
1407        _ => key.to_string(),
1408    })
1409}
1410
1411/// Validate the effective settings catalog and reject stale or conflicting
1412/// semantic projections before parser/emitter data is shipped.
1413pub fn validate_catalog() -> Result<(), Vec<String>> {
1414    use std::collections::{HashMap, HashSet};
1415
1416    let mut errors = Vec::new();
1417    errors.extend(reconciliation::validate());
1418    errors.extend(validate_raw_projection(table::raw_entries()));
1419    errors.extend(validate_enum_projection(
1420        table::ENUM_MEMBERS.iter(),
1421        table::GENERATED_ENUM_MEMBERS.iter(),
1422        &reconciliation::data().enum_member_mappings,
1423    ));
1424    let mut paths = HashSet::new();
1425    let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> =
1426        HashMap::new();
1427    let mut concept_keys: HashMap<(String, SettingTargetKind), String> = HashMap::new();
1428
1429    for definition in definitions() {
1430        if !paths.insert(definition.path.clone()) {
1431            errors.push(format!("duplicate settings path: {}", definition.path));
1432        }
1433        if definition.scope == SettingScope::Unknown {
1434            errors.push(format!("unknown settings scope: {}", definition.path));
1435        }
1436        let Some(id) = definition.id() else {
1437            errors.push(format!(
1438                "missing canonical settings identity: {}",
1439                definition.path
1440            ));
1441            continue;
1442        };
1443        if !definition.source.reviewed {
1444            errors.push(format!(
1445                "unreviewed settings definition: {}",
1446                definition.path
1447            ));
1448        }
1449        if definition.presentation.english_name.is_empty() {
1450            errors.push(format!(
1451                "missing settings presentation: {}",
1452                definition.path
1453            ));
1454        }
1455        let target_kind = definition.target_kind();
1456        let semantic_key = semantic_identity_key(definition.key);
1457        let collision_key = (id.as_str().to_string(), target_kind.clone());
1458        if let Some(previous_key) = concept_keys.insert(collision_key, semantic_key.clone()) {
1459            if previous_key != semantic_key {
1460                errors.push(format!(
1461                    "conflicting settings concepts for {id}: {previous_key} vs {semantic_key}"
1462                ));
1463            }
1464        }
1465        let key = (id.as_str().to_string(), target_kind, semantic_key);
1466        if let Some(previous) = concepts.insert(key, definition.domain.clone()) {
1467            if previous != definition.domain {
1468                errors.push(format!("conflicting settings domains for {id}"));
1469            }
1470        }
1471    }
1472    if errors.is_empty() {
1473        Ok(())
1474    } else {
1475        Err(errors)
1476    }
1477}
1478
1479/// Reject raw table overlaps unless their complete parser/emitter contract is
1480/// identical. Effective lookup may deduplicate exact repeats, but must never
1481/// make a divergent generated or fixture projection silently win.
1482fn validate_raw_projection(
1483    entries: impl IntoIterator<Item = table::ProjectedEntry>,
1484) -> Vec<String> {
1485    use std::collections::HashMap;
1486
1487    let mut errors = Vec::new();
1488    let mut paths = HashMap::new();
1489    for projected in entries {
1490        let entry = projected.entry;
1491        if let Some(previous) = paths.insert(entry.path, projected) {
1492            if previous.entry != entry
1493                && !reconciled_entry_override(
1494                    table::path_string(entry.path).as_str(),
1495                    previous,
1496                    projected,
1497                )
1498            {
1499                errors.push(format!(
1500                    "conflicting duplicate settings path between {} and {}: {}",
1501                    previous.source.label(),
1502                    projected.source.label(),
1503                    table::path_string(entry.path),
1504                ));
1505            }
1506        }
1507    }
1508    errors
1509}
1510
1511fn reconciled_entry_override(
1512    path: &str,
1513    fixture: table::ProjectedEntry,
1514    generated: table::ProjectedEntry,
1515) -> bool {
1516    use table::ProjectionSource::{FixtureTable, WorkshopDataExport};
1517
1518    let (fixture, generated) = match (fixture.source, generated.source) {
1519        (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry),
1520        (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry),
1521        _ => return false,
1522    };
1523    reconciliation::data()
1524        .entry_overrides
1525        .iter()
1526        .find(|override_| override_.path == path)
1527        .is_some_and(|override_| {
1528            entry_contract_matches(fixture, &override_.fixture)
1529                && entry_contract_matches(generated, &override_.generated)
1530        })
1531}
1532
1533fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool {
1534    entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected)
1535}
1536
1537fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool {
1538    match (kind, expected.kind.as_str(), expected.domain.as_deref()) {
1539        (KeyKind::Flag, "flag", None)
1540        | (KeyKind::String, "string", None)
1541        | (KeyKind::Bool, "bool", None)
1542        | (KeyKind::YesNo, "yesNo", None)
1543        | (KeyKind::Number, "number", None)
1544        | (KeyKind::Percent, "percent", None)
1545        | (KeyKind::ListMap, "mapList", None)
1546        | (KeyKind::ListHero, "heroList", None) => true,
1547        (KeyKind::BoolEnum(actual), "boolEnum", Some(expected)) => actual == expected,
1548        (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected,
1549        _ => false,
1550    }
1551}
1552
1553/// Validate enum members independently of entry lookup order. This catches
1554/// both stale enum projections and conflicting duplicate spellings that the
1555/// lookup helper would otherwise hide.
1556fn validate_enum_projection(
1557    fixture_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1558    generated_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1559    mappings: &[reconciliation::EnumMemberMapping],
1560) -> Vec<String> {
1561    use std::collections::{HashMap, HashSet};
1562
1563    let domains: HashSet<_> = table::entries()
1564        .filter_map(|entry| match entry.kind {
1565            KeyKind::Enum(domain) | KeyKind::BoolEnum(domain) => Some(domain),
1566            _ => None,
1567        })
1568        .collect();
1569    let mut errors = Vec::new();
1570    let mut members = HashMap::new();
1571    let mut names = HashMap::new();
1572    for member in fixture_entries {
1573        if !domains.contains(member.domain) {
1574            errors.push(format!("orphaned settings enum domain: {}", member.domain));
1575        }
1576        let key = (member.domain, member.member);
1577        if let Some(previous) = members.insert(key, member.name) {
1578            if previous != member.name {
1579                errors.push(format!(
1580                    "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1581                    member.domain, member.member, member.name
1582                ));
1583            }
1584        }
1585        if let Some(previous) = names.insert((member.domain, member.name), member.member) {
1586            if previous != member.member {
1587                errors.push(format!(
1588                    "conflicting settings enum display name {}.{:?}: {previous} vs {}",
1589                    member.domain, member.name, member.member
1590                ));
1591            }
1592        }
1593    }
1594    let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS
1595        .iter()
1596        .map(|member| ((member.domain, member.member), member))
1597        .collect();
1598    let mut mapped_sources = HashSet::new();
1599    for member in generated_entries {
1600        let key = (member.domain, member.member);
1601        if let Some(previous) = members.insert(key, member.name) {
1602            if previous != member.name {
1603                errors.push(format!(
1604                    "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1605                    member.domain, member.member, member.name
1606                ));
1607            }
1608        }
1609        let mapping = mappings.iter().find(|mapping| {
1610            mapping.source_domain == member.domain && mapping.source_member == member.member
1611        });
1612        if mapping.is_none() && !domains.contains(member.domain) {
1613            errors.push(format!("orphaned settings enum domain: {}", member.domain));
1614        }
1615        let (domain, canonical_member, name) = match mapping {
1616            Some(mapping) => {
1617                if !mapped_sources.insert((
1618                    mapping.source_domain.as_str(),
1619                    mapping.source_member.as_str(),
1620                )) {
1621                    errors.push(format!(
1622                        "duplicate settings enum reconciliation for {}.{}",
1623                        mapping.source_domain, mapping.source_member
1624                    ));
1625                }
1626                match fixture_members.get(&(
1627                    mapping.target_domain.as_str(),
1628                    mapping.target_member.as_str(),
1629                )) {
1630                    Some(target) if target.name == member.name => {
1631                        (target.domain, target.member, target.name)
1632                    }
1633                    Some(target) => {
1634                        errors.push(format!(
1635                            "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}",
1636                            mapping.source_domain, mapping.source_member,
1637                            mapping.target_domain, mapping.target_member, member.name, target.name
1638                        ));
1639                        continue;
1640                    }
1641                    None => {
1642                        errors.push(format!(
1643                            "settings enum reconciliation target is missing: {}.{} -> {}.{}",
1644                            mapping.source_domain,
1645                            mapping.source_member,
1646                            mapping.target_domain,
1647                            mapping.target_member
1648                        ));
1649                        continue;
1650                    }
1651                }
1652            }
1653            None => (member.domain, member.member, member.name),
1654        };
1655        if let Some(previous) = names.insert((domain, name), canonical_member) {
1656            if previous != canonical_member {
1657                errors.push(format!(
1658                    "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}",
1659                    domain
1660                ));
1661            }
1662        }
1663    }
1664    for mapping in mappings {
1665        if !mapped_sources.contains(&(
1666            mapping.source_domain.as_str(),
1667            mapping.source_member.as_str(),
1668        )) {
1669            errors.push(format!(
1670                "orphaned settings enum reconciliation: {}.{}",
1671                mapping.source_domain, mapping.source_member
1672            ));
1673        }
1674    }
1675    errors
1676}
1677
1678fn semantic_identity_key(key: &str) -> String {
1679    match key {
1680        "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(),
1681        _ => key.to_string(),
1682    }
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687    use super::*;
1688
1689    static DUPLICATE_PATH: [PathPart<'static>; 2] =
1690        [PathPart::Part("test"), PathPart::Part("value")];
1691    static FIXTURE_ENTRY: TableEntry = TableEntry {
1692        path: &DUPLICATE_PATH,
1693        workshop_name: "Fixture Value",
1694        kind: KeyKind::Bool,
1695    };
1696    static GENERATED_ENTRY: TableEntry = TableEntry {
1697        path: &DUPLICATE_PATH,
1698        workshop_name: "Generated Value",
1699        kind: KeyKind::Bool,
1700    };
1701    static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1702        domain: "mapRotation",
1703        member: "afterAGame",
1704        name: "After A Game",
1705    };
1706    static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1707        domain: "mapRotation",
1708        member: "afterAGame",
1709        name: "After Game",
1710    };
1711    static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember {
1712        domain: "mapRotation",
1713        member: "afterMirrorMatch",
1714        name: "After A Game",
1715    };
1716    static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1717        domain: "setting_lobby_mapRotation",
1718        member: "afterGame",
1719        name: "After A Game",
1720    };
1721
1722    fn definition(target: TargetPattern) -> SettingDefinition {
1723        SettingDefinition {
1724            identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
1725            scope: SettingScope::Heroes,
1726            path: "heroes.test.value".to_string(),
1727            path_parts: &[],
1728            key: "value",
1729            target,
1730            domain: SettingValueDomain::Boolean,
1731            enum_domain: None,
1732            presentation: SettingPresentation {
1733                english_name: "Value",
1734                locale_section: "labels",
1735            },
1736            source: SettingSource {
1737                kind: SettingSourceKind::RawWorkshopFixture,
1738                source: "test",
1739                reviewed: true,
1740            },
1741        }
1742    }
1743
1744    #[test]
1745    fn common_target_narrowing_rejects_team_and_slot_mismatches() {
1746        let team = definition(TargetPattern::Team(Some("team1".to_string())));
1747        assert_eq!(
1748            team.applicability(&SettingTarget::Hero {
1749                team: Some(TeamId::new("team2")),
1750                hero: HeroId::from(crate::gameplay::hero_ids::ANA),
1751            })
1752            .expect("applicability"),
1753            Applicability::NotApplicable
1754        );
1755
1756        let team_ability = definition(TargetPattern::TeamAbility {
1757            team: Some("team1".to_string()),
1758            slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
1759            variant: None,
1760        });
1761        let target = SettingTarget::HeroAbility {
1762            team: Some(TeamId::new("team2")),
1763            hero: HeroId::from(crate::gameplay::hero_ids::DVA),
1764            slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
1765            variant: Some(AbilityVariant::new("mech")),
1766        };
1767        assert_eq!(
1768            team_ability.applicability(&target).expect("applicability"),
1769            Applicability::NotApplicable
1770        );
1771    }
1772
1773    #[test]
1774    fn raw_projection_conflicts_include_presentation_contract() {
1775        let errors = validate_raw_projection([
1776            table::ProjectedEntry {
1777                source: table::ProjectionSource::FixtureTable,
1778                entry: &FIXTURE_ENTRY,
1779            },
1780            table::ProjectedEntry {
1781                source: table::ProjectionSource::WorkshopDataExport,
1782                entry: &GENERATED_ENTRY,
1783            },
1784        ]);
1785        assert_eq!(errors.len(), 1);
1786        assert!(errors[0].contains("fixture table"));
1787        assert!(errors[0].contains("Workshop-data export"));
1788    }
1789
1790    #[test]
1791    fn enum_projection_conflicts_are_not_hidden_by_lookup_order() {
1792        let errors =
1793            validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]);
1794        assert_eq!(errors.len(), 1);
1795        assert!(errors[0].contains("mapRotation.afterAGame"));
1796    }
1797
1798    #[test]
1799    fn enum_projection_rejects_display_name_to_identity_collisions() {
1800        let errors =
1801            validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]);
1802        assert_eq!(errors.len(), 1);
1803        assert!(errors[0].contains("conflicting settings enum display name"));
1804    }
1805
1806    #[test]
1807    fn enum_projection_reconciles_export_members_to_canonical_identities() {
1808        let mappings = [reconciliation::EnumMemberMapping {
1809            source_domain: "setting_lobby_mapRotation".to_string(),
1810            source_member: "afterGame".to_string(),
1811            target_domain: "mapRotation".to_string(),
1812            target_member: "afterAGame".to_string(),
1813        }];
1814        let errors =
1815            validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings);
1816        assert!(errors.is_empty(), "{errors:?}");
1817    }
1818}