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