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