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        apply_value(node, &id, value)
332    }
333
334    /// Build a source-text edit for one existing scalar occurrence.
335    ///
336    /// The caller retains the original source and applies the returned edit
337    /// while its targeted bytes are unchanged. Comments, whitespace, and every
338    /// other byte remain outside the edit range and are therefore preserved
339    /// without assigning them comment/trivia ownership semantics.
340    pub fn source_edit(
341        &self,
342        source: &str,
343        settings: &Settings,
344        locale: &str,
345        target: &SettingTarget,
346        value: SettingValue,
347    ) -> Result<SettingSourceEdit, SettingOperationError> {
348        let id = self.operation_id()?;
349        self.ensure_write_target(target)?;
350        let path = self.concrete_path(target);
351        let node = find_node(&settings.children, &path).ok_or_else(|| {
352            SettingOperationError::NotFound {
353                setting: id.clone(),
354                target: target.clone(),
355            }
356        })?;
357        // Same leaf gate as `read`/`write`: a group or pass-through
358        // occurrence has no scalar value bytes to edit, and
359        // `source_value_range` would carve the range out of the block.
360        value_from_node(node, &self.domain, &id)?;
361        validate_value(&self.domain, &id, &value, node.span())?;
362        let span = node
363            .span()
364            .ok_or_else(|| SettingOperationError::SourceUnavailable {
365                setting: id.clone(),
366            })?;
367        let range = source_value_range(source, span, &id)?;
368        let kind = table::lookup(self.path_parts)
369            .expect("settings definition must retain its table entry")
370            .kind;
371        let replacement = source_value_spelling(&self.domain, kind, locale, &id, value)?;
372        let edit = crate::core::source::SourceEdit::from_source(source, range, replacement)
373            .map_err(|_| SettingOperationError::SourceUnavailable {
374                setting: id.clone(),
375            })?;
376        Ok(SettingSourceEdit { edit })
377    }
378
379    fn ensure_read_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
380        let id = self.operation_id()?;
381        match self
382            .applicability(target)
383            .map_err(|error| SettingOperationError::InvalidValue {
384                setting: id.clone(),
385                message: error.to_string(),
386                span: None,
387            })? {
388            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
389                setting: id,
390                target: target.clone(),
391            }),
392            Applicability::Applicable | Applicability::Unknown => Ok(()),
393        }
394    }
395
396    fn ensure_write_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
397        let id = self.operation_id()?;
398        match self
399            .applicability(target)
400            .map_err(|error| SettingOperationError::InvalidValue {
401                setting: id.clone(),
402                message: error.to_string(),
403                span: None,
404            })? {
405            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
406                setting: id,
407                target: target.clone(),
408            }),
409            Applicability::Unknown => Err(SettingOperationError::ApplicabilityUnknown {
410                setting: id,
411                target: Box::new(target.clone()),
412            }),
413            Applicability::Applicable => Ok(()),
414        }
415    }
416
417    fn operation_id(&self) -> Result<SettingId, SettingOperationError> {
418        self.id()
419            .cloned()
420            .ok_or_else(|| SettingOperationError::InvalidValue {
421                setting: SettingId::new("unknown"),
422                message: "setting has no reviewed canonical identity".to_string(),
423                span: None,
424            })
425    }
426
427    /// For `gamemodes.general` definitions, the leaf key whose occurrences
428    /// live under each inheriting mode.
429    fn inherited_general_key(&self) -> Option<&'static str> {
430        match self.path_parts {
431            [
432                PathPart::Part("gamemodes"),
433                PathPart::Part("general"),
434                PathPart::Part(key),
435            ] => Some(key),
436            _ => None,
437        }
438    }
439
440    fn concrete_path(&self, target: &SettingTarget) -> Vec<String> {
441        let mode = match target {
442            SettingTarget::Mode(mode) => Some(mode.as_str()),
443            _ => None,
444        };
445        self.path_parts
446            .iter()
447            .enumerate()
448            .map(|(index, part)| match part {
449                // An inherited `gamemodes.general` leaf occurs under the
450                // requested mode's group.
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(SettingOperationError::InvalidValue {
568                setting: setting.clone(),
569                message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
570                span: None,
571            })
572        }
573        (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
574            Ok(crate::format::format_setting_number(value))
575        }
576        (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
577            Ok(format!("{}%", crate::format::format_setting_number(value)))
578        }
579        (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
580            "\"{}\"",
581            crate::output::emitter::escape_settings_string(&value)
582        )),
583        (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
584            let english = table::enum_name(domain, &member).ok_or_else(|| {
585                SettingOperationError::InvalidValue {
586                    setting: setting.clone(),
587                    message: format!("unknown member '{member}' for enum domain '{domain}'"),
588                    span: None,
589                }
590            })?;
591            localized("enums", english)
592        }
593        _ => Err(SettingOperationError::SourceUnavailable {
594            setting: setting.clone(),
595        }),
596    }
597}
598
599fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
600    let (name, rest) = path.split_first()?;
601    let node = children.iter().find(|node| node.name() == name)?;
602    if rest.is_empty() {
603        Some(node)
604    } else {
605        match node {
606            SettingsNode::Workshop { children, .. }
607            | SettingsNode::Group { children, .. }
608            | SettingsNode::RawGroup { children, .. } => find_node(children, rest),
609            _ => None,
610        }
611    }
612}
613
614fn find_node_mut<'a>(
615    children: &'a mut [SettingsNode],
616    path: &[String],
617) -> Option<&'a mut SettingsNode> {
618    let (name, rest) = path.split_first()?;
619    let node = children.iter_mut().find(|node| node.name() == name)?;
620    if rest.is_empty() {
621        Some(node)
622    } else {
623        match node {
624            SettingsNode::Workshop { children, .. }
625            | SettingsNode::Group { children, .. }
626            | SettingsNode::RawGroup { children, .. } => find_node_mut(children, rest),
627            _ => None,
628        }
629    }
630}
631
632fn validate_value(
633    domain: &SettingValueDomain,
634    id: &SettingId,
635    value: &SettingValue,
636    span: Option<crate::core::source::Span>,
637) -> Result<(), SettingOperationError> {
638    let expected = domain.kind();
639    if value.kind() != expected {
640        return Err(SettingOperationError::WrongValueKind {
641            setting: id.clone(),
642            expected,
643            actual: value.kind(),
644            span,
645        });
646    }
647    match (domain, value) {
648        (
649            SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
650            SettingValue::Number(value) | SettingValue::Percent(value),
651        ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
652            setting: id.clone(),
653            message: "numeric settings values must be finite".to_string(),
654            span,
655        }),
656        (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
657            if table::enum_name(domain, member).is_none() =>
658        {
659            Err(SettingOperationError::InvalidValue {
660                setting: id.clone(),
661                message: format!("unknown member '{member}' for enum domain '{domain}'"),
662                span,
663            })
664        }
665        (SettingValueDomain::HeroList, SettingValue::HeroList(values))
666            if values.iter().any(|value| table::hero_name(value).is_none()) =>
667        {
668            Err(SettingOperationError::InvalidValue {
669                setting: id.clone(),
670                message: "hero list contains an unknown hero".to_string(),
671                span,
672            })
673        }
674        (SettingValueDomain::MapList, SettingValue::MapList(values))
675            if values.iter().any(|value| table::map_name(value).is_none()) =>
676        {
677            Err(SettingOperationError::InvalidValue {
678                setting: id.clone(),
679                message: "map list contains an unknown map".to_string(),
680                span,
681            })
682        }
683        _ => Ok(()),
684    }
685}
686
687fn is_typed_leaf(node: &SettingsNode) -> bool {
688    matches!(
689        node,
690        SettingsNode::Bool { .. }
691            | SettingsNode::Number { .. }
692            | SettingsNode::String { .. }
693            | SettingsNode::Flag { .. }
694            | SettingsNode::List { .. }
695    )
696}
697
698fn not_typed_leaf(node: &SettingsNode, id: &SettingId) -> SettingOperationError {
699    SettingOperationError::InvalidValue {
700        setting: id.clone(),
701        message: "settings occurrence is not a typed leaf".to_string(),
702        span: node.span(),
703    }
704}
705
706fn value_from_node(
707    node: &SettingsNode,
708    domain: &SettingValueDomain,
709    id: &SettingId,
710) -> Result<SettingValue, SettingOperationError> {
711    let value = match node {
712        SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
713        SettingsNode::Number { value, .. } => match domain {
714            SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
715            _ => SettingValue::Number(*value),
716        },
717        SettingsNode::String { value, .. } => match domain {
718            SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
719            _ => SettingValue::String(value.clone()),
720        },
721        SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
722        SettingsNode::List { elements, .. } => {
723            let values = elements
724                .iter()
725                .map(|element| element.value.clone())
726                .collect();
727            match domain {
728                SettingValueDomain::HeroList => SettingValue::HeroList(values),
729                _ => SettingValue::MapList(values),
730            }
731        }
732        _ => return Err(not_typed_leaf(node, id)),
733    };
734    validate_value(domain, id, &value, node.span())?;
735    Ok(value)
736}
737
738fn apply_value(
739    node: &mut SettingsNode,
740    id: &SettingId,
741    value: SettingValue,
742) -> Result<(), SettingOperationError> {
743    match (node, value) {
744        (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
745            *current = value
746        }
747        (
748            SettingsNode::Number { value: current, .. },
749            SettingValue::Number(value) | SettingValue::Percent(value),
750        ) => *current = value,
751        (
752            SettingsNode::String { value: current, .. },
753            SettingValue::String(value) | SettingValue::Enum(value),
754        ) => *current = value,
755        (
756            SettingsNode::List { elements, span, .. },
757            SettingValue::HeroList(values) | SettingValue::MapList(values),
758        ) => {
759            if elements.len() != values.len() {
760                return Err(SettingOperationError::InvalidValue {
761                    setting: id.clone(),
762                    message: "source-preserving list edits cannot change list length".to_string(),
763                    span: *span,
764                });
765            }
766            elements
767                .iter_mut()
768                .zip(values)
769                .for_each(|(element, value)| element.value = value);
770        }
771        (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
772        (node, value) => {
773            return Err(SettingOperationError::WrongValueKind {
774                setting: id.clone(),
775                expected: "existing typed value",
776                actual: value.kind(),
777                span: node.span(),
778            });
779        }
780    }
781    Ok(())
782}
783
784fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
785    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
786}
787
788fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
789    match target {
790        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
791        _ => None,
792    }
793}
794
795fn hero_ability_exists(
796    hero: &HeroId,
797    slot: &LogicalSlot,
798    variant: Option<&AbilityVariant>,
799) -> Result<Option<bool>, GameplayDataError> {
800    data::builtin_ref().map_err(Clone::clone).map(|catalog| {
801        catalog.hero(hero).map(|hero| match variant {
802            Some(variant) => hero.ability_variant(slot, variant).is_ok(),
803            None => !hero.abilities_in_slot(slot).is_empty(),
804        })
805    })
806}