Skip to main content

workshop_rs/settings/schema/
operations.rs

1use std::ops::Range;
2
3use super::super::table::{self, KeyKind};
4use super::*;
5use crate::gameplay::{AbilityVariant, GameplayDataError, HeroId, LogicalSlot, data};
6
7impl SettingSourceEdit {
8    pub fn range(&self) -> Range<usize> {
9        self.edit.range()
10    }
11
12    pub fn replacement(&self) -> &str {
13        self.edit.replacement()
14    }
15
16    /// Apply this edit when the targeted source bytes are unchanged.
17    pub fn apply(&self, source: &str) -> Result<String, SettingOperationError> {
18        self.edit
19            .apply(source)
20            .map_err(|_| SettingOperationError::SourceMismatch)
21    }
22}
23
24impl SettingPresentation {
25    pub fn localized_name(&self, locale: &str) -> Option<&'static str> {
26        if locale.eq_ignore_ascii_case("en-US") {
27            Some(self.english_name)
28        } else {
29            table::localized_name(locale, self.locale_section, self.english_name)
30        }
31    }
32}
33
34impl SettingDefinition {
35    pub fn identity(&self) -> &SettingIdentity {
36        &self.identity
37    }
38
39    pub fn id(&self) -> Option<&SettingId> {
40        match &self.identity {
41            SettingIdentity::Known(id) => Some(id),
42            SettingIdentity::Unknown => None,
43        }
44    }
45
46    pub fn scope(&self) -> SettingScope {
47        self.scope
48    }
49
50    pub fn path(&self) -> &str {
51        &self.path
52    }
53
54    /// The canonical path as typed segments: literal keys and the `<team>`
55    /// / `<hero>` template slots a concrete path fills.
56    pub(crate) fn path_parts(&self) -> &[PathPart<'static>] {
57        self.path_parts
58    }
59
60    pub fn domain(&self) -> &SettingValueDomain {
61        &self.domain
62    }
63
64    /// Enumerate the accepted members when this setting uses an enum value.
65    /// Boolean settings backed by an enum token (such as `Enabled`) expose
66    /// that token here as well.
67    pub fn enum_members(&self) -> impl Iterator<Item = SettingEnumMember> + '_ {
68        self.enum_domain
69            .into_iter()
70            .flat_map(table::enum_members)
71            .map(|member| SettingEnumMember {
72                domain: member.domain,
73                id: member.member,
74                english_name: member.name,
75            })
76    }
77
78    pub fn target_kind(&self) -> SettingTargetKind {
79        match &self.target {
80            TargetPattern::Global => SettingTargetKind::Global,
81            TargetPattern::Mode(_) => SettingTargetKind::Mode,
82            TargetPattern::Team(_) => SettingTargetKind::Team,
83            TargetPattern::TeamAbility { slot, variant, .. } => SettingTargetKind::TeamAbility {
84                slot: slot.clone(),
85                variant: variant.clone(),
86            },
87            TargetPattern::Hero { .. } => SettingTargetKind::Hero,
88            TargetPattern::HeroAbility { slot, variant, .. } => SettingTargetKind::HeroAbility {
89                slot: slot.clone(),
90                variant: variant.clone(),
91            },
92            TargetPattern::Unknown => SettingTargetKind::Unknown,
93        }
94    }
95
96    pub fn presentation(&self) -> &SettingPresentation {
97        &self.presentation
98    }
99
100    pub fn localized_name(
101        &self,
102        locale: &str,
103        target: &SettingTarget,
104    ) -> Result<Option<&'static str>, GameplayDataError> {
105        match target {
106            SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
107                if self.applicability(target)? == Applicability::NotApplicable {
108                    Ok(None)
109                } else {
110                    Ok(table::hero_setting_name(hero.as_str(), self.key, locale)
111                        .or_else(|| self.presentation.localized_name(locale)))
112                }
113            }
114            _ => Ok(self.presentation.localized_name(locale)),
115        }
116    }
117
118    pub fn source(&self) -> SettingSource {
119        self.source
120    }
121
122    /// Query effective applicability without exposing table deduplication.
123    pub fn applicability(
124        &self,
125        target: &SettingTarget,
126    ) -> Result<Applicability, GameplayDataError> {
127        Ok(match (&self.target, target) {
128            (TargetPattern::Global, SettingTarget::Global) => Applicability::Applicable,
129            (TargetPattern::Mode(expected), SettingTarget::Mode(actual)) => {
130                let applies = match self.inherited_general_key() {
131                    // A `gamemodes.general` leaf applies to a mode only when
132                    // the mode inherits it (`elimination` copies a subset);
133                    // `general` itself is the literal authored group.
134                    Some(key) => {
135                        actual == "general"
136                            || table::mode_inherited_entry(&[
137                                PathPart::Part("gamemodes"),
138                                PathPart::Part(actual.as_str()),
139                                PathPart::Part(key),
140                            ])
141                            .is_some()
142                    }
143                    None => expected
144                        .as_deref()
145                        .is_none_or(|expected| expected == actual),
146                };
147                if applies {
148                    Applicability::Applicable
149                } else {
150                    Applicability::NotApplicable
151                }
152            }
153            (TargetPattern::Team(expected), SettingTarget::Team(actual)) => {
154                if expected
155                    .as_deref()
156                    .is_none_or(|expected| expected == actual.as_str())
157                {
158                    Applicability::Applicable
159                } else {
160                    Applicability::NotApplicable
161                }
162            }
163            (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => {
164                if team_matches(expected.as_deref(), team.as_ref()) {
165                    Applicability::Unknown
166                } else {
167                    Applicability::NotApplicable
168                }
169            }
170            (
171                TargetPattern::TeamAbility {
172                    team,
173                    slot,
174                    variant: expected_variant,
175                },
176                SettingTarget::TeamAbility {
177                    team: actual_team,
178                    slot: actual_slot,
179                    variant: actual_variant,
180                },
181            ) => {
182                if !team_matches(team.as_deref(), actual_team.as_ref())
183                    || slot != actual_slot
184                    || expected_variant
185                        .as_ref()
186                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
187                {
188                    Applicability::NotApplicable
189                } else {
190                    Applicability::Applicable
191                }
192            }
193            (
194                TargetPattern::TeamAbility {
195                    team,
196                    slot,
197                    variant: expected_variant,
198                },
199                SettingTarget::HeroAbility {
200                    team: actual_team,
201                    hero: actual_hero,
202                    slot: actual_slot,
203                    variant: actual_variant,
204                },
205            ) => {
206                if !team_matches(team.as_deref(), actual_team.as_ref())
207                    || slot != actual_slot
208                    || expected_variant
209                        .as_ref()
210                        .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
211                {
212                    Applicability::NotApplicable
213                } else {
214                    match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? {
215                        Some(true) => Applicability::Unknown,
216                        Some(false) => Applicability::NotApplicable,
217                        None => Applicability::Unknown,
218                    }
219                }
220            }
221            (
222                TargetPattern::Hero { team, hero },
223                SettingTarget::Hero {
224                    team: actual_team,
225                    hero: actual_hero,
226                },
227            ) => {
228                if !team_matches(team.as_deref(), actual_team.as_ref())
229                    || hero
230                        .as_deref()
231                        .is_some_and(|expected| expected != actual_hero.as_str())
232                {
233                    Applicability::NotApplicable
234                } else {
235                    Applicability::Unknown
236                }
237            }
238            (
239                TargetPattern::HeroAbility {
240                    team,
241                    hero,
242                    slot,
243                    variant: expected_variant,
244                },
245                SettingTarget::HeroAbility {
246                    team: actual_team,
247                    hero: actual_hero,
248                    slot: actual_slot,
249                    ..
250                },
251            ) => {
252                if !team_matches(team.as_deref(), actual_team.as_ref())
253                    || hero
254                        .as_deref()
255                        .is_some_and(|expected| expected != actual_hero.as_str())
256                    || slot.as_str() != actual_slot.as_str()
257                    || expected_variant
258                        .as_ref()
259                        .is_some_and(|expected| Some(expected) != target_variant(target))
260                {
261                    return Ok(Applicability::NotApplicable);
262                }
263                match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? {
264                    None => Applicability::Unknown,
265                    Some(false) => Applicability::NotApplicable,
266                    Some(true) => Applicability::Unknown,
267                }
268            }
269            (TargetPattern::Unknown, _) => Applicability::Unknown,
270            _ => Applicability::NotApplicable,
271        })
272    }
273
274    pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
275        self.domain.effective_number(authored)
276    }
277
278    /// Read an existing source-preserving occurrence with its authored value
279    /// and, when source-backed, its effective numeric value.
280    pub fn read(
281        &self,
282        settings: &Settings,
283        target: &SettingTarget,
284    ) -> Result<SettingOccurrence, SettingOperationError> {
285        let id = self.operation_id()?;
286        self.ensure_read_target(target)?;
287        let path = self.concrete_path(target);
288        let node = find_node(&settings.children, &path).ok_or_else(|| {
289            SettingOperationError::NotFound {
290                setting: id.clone(),
291                target: target.clone(),
292            }
293        })?;
294        let authored = value_from_node(node, &self.domain, &id)?;
295        let effective = match authored {
296            SettingValue::Number(value) | SettingValue::Percent(value) => {
297                self.effective_number(value)
298            }
299            _ => None,
300        };
301        Ok(SettingOccurrence {
302            authored,
303            effective,
304        })
305    }
306
307    /// Update one existing occurrence without rebuilding the surrounding
308    /// settings tree. Unknown and unrelated source structure is untouched.
309    pub fn write(
310        &self,
311        settings: &mut Settings,
312        target: &SettingTarget,
313        value: SettingValue,
314    ) -> Result<(), SettingOperationError> {
315        let id = self.operation_id()?;
316        self.ensure_write_target(target)?;
317        let path = self.concrete_path(target);
318        let node = find_node_mut(&mut settings.children, &path).ok_or_else(|| {
319            SettingOperationError::NotFound {
320                setting: id.clone(),
321                target: target.clone(),
322            }
323        })?;
324        // Same leaf gate as `read`/`source_edit`: refuse a group or
325        // pass-through occurrence before judging the supplied value.
326        if !is_typed_leaf(node) {
327            return Err(not_typed_leaf(node, &id));
328        }
329        let span = node.span();
330        validate_value(&self.domain, &id, &value, span)?;
331        // Same unsupported-value gate `source_edit` applies before
332        // spelling: writing a value the carrier cannot emit — a
333        // boolean-enum leaf has no authored `false` form — must fail here
334        // rather than produce an unemittable occurrence.
335        let kind = table::lookup(self.path_parts)
336            .expect("settings definition must retain its table entry")
337            .kind;
338        ensure_supported_value(kind, &id, &value, span)?;
339        apply_value(node, &id, value)
340    }
341
342    /// Build a source-text edit for one existing scalar occurrence.
343    ///
344    /// The caller retains the original source and applies the returned edit
345    /// while its targeted bytes are unchanged. Comments, whitespace, and every
346    /// other byte remain outside the edit range and are therefore preserved
347    /// without assigning them comment/trivia ownership semantics.
348    pub fn source_edit(
349        &self,
350        source: &str,
351        settings: &Settings,
352        locale: &str,
353        target: &SettingTarget,
354        value: SettingValue,
355    ) -> Result<SettingSourceEdit, SettingOperationError> {
356        let id = self.operation_id()?;
357        self.ensure_write_target(target)?;
358        let path = self.concrete_path(target);
359        let node = find_node(&settings.children, &path).ok_or_else(|| {
360            SettingOperationError::NotFound {
361                setting: id.clone(),
362                target: target.clone(),
363            }
364        })?;
365        let span = node.span();
366        // Same leaf gate as `read`/`write`: a group or pass-through
367        // occurrence has no scalar value bytes to edit, and
368        // `source_value_range` would carve the range out of the block.
369        let Some(authored) = typed_leaf_value(node, &self.domain) else {
370            return Err(not_typed_leaf(node, &id));
371        };
372        validate_value(&self.domain, &id, &value, span)?;
373        let kind = table::lookup(self.path_parts)
374            .expect("settings definition must retain its table entry")
375            .kind;
376        ensure_supported_value(kind, &id, &value, span)?;
377        // Same carrier-shape agreement `write` reaches through
378        // `apply_value`: the leaf's authored value kind must be the
379        // domain's before the edit may replace the member's value bytes.
380        // The authored value itself is replaced wholesale, so only its
381        // kind is checked — never the value.
382        if authored.kind() != self.domain.kind() {
383            return Err(SettingOperationError::WrongValueKind {
384                setting: id.clone(),
385                expected: "existing typed value",
386                actual: value.kind(),
387                span,
388            });
389        }
390        let span = span.ok_or_else(|| SettingOperationError::SourceUnavailable {
391            setting: id.clone(),
392        })?;
393        let range = source_value_range(source, span, &id)?;
394        let replacement = source_value_spelling(&self.domain, kind, locale, &id, value)?;
395        let edit = crate::core::source::SourceEdit::from_source(source, range, replacement)
396            .map_err(|_| SettingOperationError::SourceUnavailable {
397                setting: id.clone(),
398            })?;
399        Ok(SettingSourceEdit { edit })
400    }
401
402    fn ensure_read_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
403        let id = self.operation_id()?;
404        match self
405            .applicability(target)
406            .map_err(|error| SettingOperationError::InvalidValue {
407                setting: id.clone(),
408                message: error.to_string(),
409                span: None,
410            })? {
411            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
412                setting: id,
413                target: target.clone(),
414            }),
415            Applicability::Applicable | Applicability::Unknown => Ok(()),
416        }
417    }
418
419    fn ensure_write_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
420        let id = self.operation_id()?;
421        match self
422            .applicability(target)
423            .map_err(|error| SettingOperationError::InvalidValue {
424                setting: id.clone(),
425                message: error.to_string(),
426                span: None,
427            })? {
428            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
429                setting: id,
430                target: target.clone(),
431            }),
432            Applicability::Unknown => Err(SettingOperationError::ApplicabilityUnknown {
433                setting: id,
434                target: Box::new(target.clone()),
435            }),
436            Applicability::Applicable => Ok(()),
437        }
438    }
439
440    fn operation_id(&self) -> Result<SettingId, SettingOperationError> {
441        self.id()
442            .cloned()
443            .ok_or_else(|| SettingOperationError::InvalidValue {
444                setting: SettingId::new("unknown"),
445                message: "setting has no reviewed canonical identity".to_string(),
446                span: None,
447            })
448    }
449
450    /// For `gamemodes.general` definitions, the leaf key whose occurrences
451    /// live under each inheriting mode.
452    fn inherited_general_key(&self) -> Option<&'static str> {
453        match self.path_parts {
454            [
455                PathPart::Part("gamemodes"),
456                PathPart::Part("general"),
457                PathPart::Part(key),
458            ] => Some(key),
459            _ => None,
460        }
461    }
462
463    fn concrete_path(&self, target: &SettingTarget) -> Vec<String> {
464        let mode = match target {
465            SettingTarget::Mode(mode) => Some(mode.as_str()),
466            _ => None,
467        };
468        self.path_parts
469            .iter()
470            .enumerate()
471            .map(|(index, part)| match part {
472                // An inherited `gamemodes.general` leaf occurs under the
473                // requested mode's group.
474                PathPart::Part("general")
475                    if index == 1 && self.inherited_general_key().is_some() && mode.is_some() =>
476                {
477                    mode.unwrap().to_string()
478                }
479                PathPart::Part(name) => (*name).to_string(),
480                PathPart::Team => target_team(target),
481                PathPart::Hero => target_hero(target),
482            })
483            .collect()
484    }
485}
486
487fn target_team(target: &SettingTarget) -> String {
488    match target {
489        SettingTarget::Team(team)
490        | SettingTarget::Hero {
491            team: Some(team), ..
492        }
493        | SettingTarget::TeamAbility {
494            team: Some(team), ..
495        }
496        | SettingTarget::HeroAbility {
497            team: Some(team), ..
498        } => team.as_str().to_string(),
499        _ => "allTeams".to_string(),
500    }
501}
502
503fn target_hero(target: &SettingTarget) -> String {
504    match target {
505        SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
506            hero.as_str().to_string()
507        }
508        _ => String::new(),
509    }
510}
511
512fn source_value_range(
513    source: &str,
514    span: crate::core::source::Span,
515    setting: &SettingId,
516) -> Result<Range<usize>, SettingOperationError> {
517    let start = crate::core::source::byte_offset(source, span.start).ok_or_else(|| {
518        SettingOperationError::SourceUnavailable {
519            setting: setting.clone(),
520        }
521    })?;
522    let end = crate::core::source::byte_offset(source, span.end).ok_or_else(|| {
523        SettingOperationError::SourceUnavailable {
524            setting: setting.clone(),
525        }
526    })?;
527    let member =
528        source
529            .get(start..end)
530            .ok_or_else(|| SettingOperationError::SourceUnavailable {
531                setting: setting.clone(),
532            })?;
533    let Some(colon) = member.find(':') else {
534        return Err(SettingOperationError::SourceUnavailable {
535            setting: setting.clone(),
536        });
537    };
538    let value_start = start + colon + 1;
539    let leading = source[value_start..end].len()
540        - source[value_start..end]
541            .trim_start_matches(char::is_whitespace)
542            .len();
543    let range = value_start + leading..end;
544    if range.is_empty() || source.get(range.clone()).is_none() {
545        return Err(SettingOperationError::SourceUnavailable {
546            setting: setting.clone(),
547        });
548    }
549    Ok(range)
550}
551
552fn source_value_spelling(
553    domain: &SettingValueDomain,
554    kind: KeyKind,
555    locale: &str,
556    setting: &SettingId,
557    value: SettingValue,
558) -> Result<String, SettingOperationError> {
559    let localized = |section: &str, english: &str| {
560        if locale.eq_ignore_ascii_case("en-US") {
561            Some(english)
562        } else {
563            table::localized_name(locale, section, english)
564        }
565        .map(str::to_string)
566        .ok_or_else(|| SettingOperationError::InvalidValue {
567            setting: setting.clone(),
568            message: format!("missing {section} locale mapping for '{english}' in {locale}"),
569            span: None,
570        })
571    };
572    match (domain, kind, value) {
573        (SettingValueDomain::Boolean, KeyKind::Bool, SettingValue::Boolean(value)) => {
574            localized("tokens", if value { "On" } else { "Off" })
575        }
576        (SettingValueDomain::Boolean, KeyKind::YesNo, SettingValue::Boolean(value)) => {
577            localized("tokens", if value { "Yes" } else { "No" })
578        }
579        (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => {
580            let english = table::enum_name(domain, "enabled").ok_or_else(|| {
581                SettingOperationError::InvalidValue {
582                    setting: setting.clone(),
583                    message: format!("unknown enabled member for enum domain '{domain}'"),
584                    span: None,
585                }
586            })?;
587            localized("enums", english)
588        }
589        (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
590            Err(bool_enum_false_unsupported(setting, None))
591        }
592        (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
593            Ok(crate::format::format_setting_number(value))
594        }
595        (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
596            Ok(format!("{}%", crate::format::format_setting_number(value)))
597        }
598        (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
599            "\"{}\"",
600            crate::output::emitter::escape_settings_string(&value)
601        )),
602        (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
603            let english = table::enum_name(domain, &member).ok_or_else(|| {
604                SettingOperationError::InvalidValue {
605                    setting: setting.clone(),
606                    message: format!("unknown member '{member}' for enum domain '{domain}'"),
607                    span: None,
608                }
609            })?;
610            localized("enums", english)
611        }
612        _ => Err(SettingOperationError::SourceUnavailable {
613            setting: setting.clone(),
614        }),
615    }
616}
617
618fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
619    let (name, rest) = path.split_first()?;
620    let node = children.iter().find(|node| node.name() == name)?;
621    if rest.is_empty() {
622        Some(node)
623    } else {
624        match node {
625            SettingsNode::Workshop { children, .. }
626            | SettingsNode::Group { children, .. }
627            | SettingsNode::RawGroup { children, .. } => find_node(children, rest),
628            _ => None,
629        }
630    }
631}
632
633fn find_node_mut<'a>(
634    children: &'a mut [SettingsNode],
635    path: &[String],
636) -> Option<&'a mut SettingsNode> {
637    let (name, rest) = path.split_first()?;
638    let node = children.iter_mut().find(|node| node.name() == name)?;
639    if rest.is_empty() {
640        Some(node)
641    } else {
642        match node {
643            SettingsNode::Workshop { children, .. }
644            | SettingsNode::Group { children, .. }
645            | SettingsNode::RawGroup { children, .. } => find_node_mut(children, rest),
646            _ => None,
647        }
648    }
649}
650
651fn validate_value(
652    domain: &SettingValueDomain,
653    id: &SettingId,
654    value: &SettingValue,
655    span: Option<crate::core::source::Span>,
656) -> Result<(), SettingOperationError> {
657    let expected = domain.kind();
658    if value.kind() != expected {
659        return Err(SettingOperationError::WrongValueKind {
660            setting: id.clone(),
661            expected,
662            actual: value.kind(),
663            span,
664        });
665    }
666    match (domain, value) {
667        (
668            SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
669            SettingValue::Number(value) | SettingValue::Percent(value),
670        ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
671            setting: id.clone(),
672            message: "numeric settings values must be finite".to_string(),
673            span,
674        }),
675        (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
676            if table::enum_name(domain, member).is_none() =>
677        {
678            Err(SettingOperationError::InvalidValue {
679                setting: id.clone(),
680                message: format!("unknown member '{member}' for enum domain '{domain}'"),
681                span,
682            })
683        }
684        (SettingValueDomain::HeroList, SettingValue::HeroList(values))
685            if values.iter().any(|value| table::hero_name(value).is_none()) =>
686        {
687            Err(SettingOperationError::InvalidValue {
688                setting: id.clone(),
689                message: "hero list contains an unknown hero".to_string(),
690                span,
691            })
692        }
693        (SettingValueDomain::MapList, SettingValue::MapList(values))
694            if values.iter().any(|value| table::map_name(value).is_none()) =>
695        {
696            Err(SettingOperationError::InvalidValue {
697                setting: id.clone(),
698                message: "map list contains an unknown map".to_string(),
699                span,
700            })
701        }
702        _ => Ok(()),
703    }
704}
705
706fn is_typed_leaf(node: &SettingsNode) -> bool {
707    matches!(
708        node,
709        SettingsNode::Bool { .. }
710            | SettingsNode::Number { .. }
711            | SettingsNode::String { .. }
712            | SettingsNode::Flag { .. }
713            | SettingsNode::List { .. }
714    )
715}
716
717fn not_typed_leaf(node: &SettingsNode, id: &SettingId) -> SettingOperationError {
718    SettingOperationError::InvalidValue {
719        setting: id.clone(),
720        message: "settings occurrence is not a typed leaf".to_string(),
721        span: node.span(),
722    }
723}
724
725/// The typed value a leaf occurrence carries for `domain` — the shared
726/// carrier-shape mapping behind `value_from_node` and the `source_edit`
727/// leaf gate. `None` for a group or pass-through occurrence.
728fn typed_leaf_value(node: &SettingsNode, domain: &SettingValueDomain) -> Option<SettingValue> {
729    let value = match node {
730        SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
731        SettingsNode::Number { value, .. } => match domain {
732            SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
733            _ => SettingValue::Number(*value),
734        },
735        SettingsNode::String { value, .. } => match domain {
736            SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
737            _ => SettingValue::String(value.clone()),
738        },
739        SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
740        SettingsNode::List { elements, .. } => {
741            let values = elements
742                .iter()
743                .map(|element| element.value.clone())
744                .collect();
745            match domain {
746                SettingValueDomain::HeroList => SettingValue::HeroList(values),
747                _ => SettingValue::MapList(values),
748            }
749        }
750        _ => return None,
751    };
752    Some(value)
753}
754
755/// The supplied value must have an authored form for this key kind — the
756/// check `write` and `source_edit` share before mutating or editing. A
757/// boolean-enum leaf renders `true` through its enabled-member token only;
758/// `false` has no authored spelling and the emitter rejects the carrier,
759/// so both paths refuse it identically.
760fn ensure_supported_value(
761    kind: KeyKind,
762    id: &SettingId,
763    value: &SettingValue,
764    span: Option<crate::core::source::Span>,
765) -> Result<(), SettingOperationError> {
766    if matches!(
767        (kind, value),
768        (KeyKind::BoolEnum(_), SettingValue::Boolean(false))
769    ) {
770        return Err(bool_enum_false_unsupported(id, span));
771    }
772    Ok(())
773}
774
775fn bool_enum_false_unsupported(
776    id: &SettingId,
777    span: Option<crate::core::source::Span>,
778) -> SettingOperationError {
779    SettingOperationError::InvalidValue {
780        setting: id.clone(),
781        message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
782        span,
783    }
784}
785
786fn value_from_node(
787    node: &SettingsNode,
788    domain: &SettingValueDomain,
789    id: &SettingId,
790) -> Result<SettingValue, SettingOperationError> {
791    let value = typed_leaf_value(node, domain).ok_or_else(|| not_typed_leaf(node, id))?;
792    validate_value(domain, id, &value, node.span())?;
793    Ok(value)
794}
795
796fn apply_value(
797    node: &mut SettingsNode,
798    id: &SettingId,
799    value: SettingValue,
800) -> Result<(), SettingOperationError> {
801    match (node, value) {
802        (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
803            *current = value
804        }
805        (
806            SettingsNode::Number { value: current, .. },
807            SettingValue::Number(value) | SettingValue::Percent(value),
808        ) => *current = value,
809        (
810            SettingsNode::String { value: current, .. },
811            SettingValue::String(value) | SettingValue::Enum(value),
812        ) => *current = value,
813        (
814            SettingsNode::List { elements, span, .. },
815            SettingValue::HeroList(values) | SettingValue::MapList(values),
816        ) => {
817            if elements.len() != values.len() {
818                return Err(SettingOperationError::InvalidValue {
819                    setting: id.clone(),
820                    message: "source-preserving list edits cannot change list length".to_string(),
821                    span: *span,
822                });
823            }
824            elements
825                .iter_mut()
826                .zip(values)
827                .for_each(|(element, value)| element.value = value);
828        }
829        (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
830        (node, value) => {
831            return Err(SettingOperationError::WrongValueKind {
832                setting: id.clone(),
833                expected: "existing typed value",
834                actual: value.kind(),
835                span: node.span(),
836            });
837        }
838    }
839    Ok(())
840}
841
842fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
843    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
844}
845
846fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
847    match target {
848        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
849        _ => None,
850    }
851}
852
853fn hero_ability_exists(
854    hero: &HeroId,
855    slot: &LogicalSlot,
856    variant: Option<&AbilityVariant>,
857) -> Result<Option<bool>, GameplayDataError> {
858    data::builtin_ref().map_err(Clone::clone).map(|catalog| {
859        catalog.hero(hero).map(|hero| match variant {
860            Some(variant) => hero.ability_variant(slot, variant).is_ok(),
861            None => !hero.abilities_in_slot(slot).is_empty(),
862        })
863    })
864}