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        let span = node.span();
325        validate_value(&self.domain, &id, &value, span)?;
326        apply_value(node, &id, value)
327    }
328
329    /// Build a source-text edit for one existing scalar occurrence.
330    ///
331    /// The caller retains the original source and applies the returned edit
332    /// while its targeted bytes are unchanged. Comments, whitespace, and every
333    /// other byte remain outside the edit range and are therefore preserved
334    /// without assigning them comment/trivia ownership semantics.
335    pub fn source_edit(
336        &self,
337        source: &str,
338        settings: &Settings,
339        locale: &str,
340        target: &SettingTarget,
341        value: SettingValue,
342    ) -> Result<SettingSourceEdit, SettingOperationError> {
343        let id = self.operation_id()?;
344        self.ensure_write_target(target)?;
345        let path = self.concrete_path(target);
346        let node = find_node(&settings.children, &path).ok_or_else(|| {
347            SettingOperationError::NotFound {
348                setting: id.clone(),
349                target: target.clone(),
350            }
351        })?;
352        // Same leaf gate as `read`/`write`: a group or pass-through
353        // occurrence has no scalar value bytes to edit, and
354        // `source_value_range` would carve the range out of the block.
355        value_from_node(node, &self.domain, &id)?;
356        validate_value(&self.domain, &id, &value, node.span())?;
357        let span = node
358            .span()
359            .ok_or_else(|| SettingOperationError::SourceUnavailable {
360                setting: id.clone(),
361            })?;
362        let range = source_value_range(source, span, &id)?;
363        let kind = table::lookup(self.path_parts)
364            .expect("settings definition must retain its table entry")
365            .kind;
366        let replacement = source_value_spelling(&self.domain, kind, locale, &id, value)?;
367        let edit = crate::core::source::SourceEdit::from_source(source, range, replacement)
368            .map_err(|_| SettingOperationError::SourceUnavailable {
369                setting: id.clone(),
370            })?;
371        Ok(SettingSourceEdit { edit })
372    }
373
374    fn ensure_read_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
375        let id = self.operation_id()?;
376        match self
377            .applicability(target)
378            .map_err(|error| SettingOperationError::InvalidValue {
379                setting: id.clone(),
380                message: error.to_string(),
381                span: None,
382            })? {
383            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
384                setting: id,
385                target: target.clone(),
386            }),
387            Applicability::Applicable | Applicability::Unknown => Ok(()),
388        }
389    }
390
391    fn ensure_write_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
392        let id = self.operation_id()?;
393        match self
394            .applicability(target)
395            .map_err(|error| SettingOperationError::InvalidValue {
396                setting: id.clone(),
397                message: error.to_string(),
398                span: None,
399            })? {
400            Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
401                setting: id,
402                target: target.clone(),
403            }),
404            Applicability::Unknown => Err(SettingOperationError::ApplicabilityUnknown {
405                setting: id,
406                target: Box::new(target.clone()),
407            }),
408            Applicability::Applicable => Ok(()),
409        }
410    }
411
412    fn operation_id(&self) -> Result<SettingId, SettingOperationError> {
413        self.id()
414            .cloned()
415            .ok_or_else(|| SettingOperationError::InvalidValue {
416                setting: SettingId::new("unknown"),
417                message: "setting has no reviewed canonical identity".to_string(),
418                span: None,
419            })
420    }
421
422    /// For `gamemodes.general` definitions, the leaf key whose occurrences
423    /// live under each inheriting mode.
424    fn inherited_general_key(&self) -> Option<&'static str> {
425        match self.path_parts {
426            [
427                PathPart::Part("gamemodes"),
428                PathPart::Part("general"),
429                PathPart::Part(key),
430            ] => Some(key),
431            _ => None,
432        }
433    }
434
435    fn concrete_path(&self, target: &SettingTarget) -> Vec<String> {
436        let mode = match target {
437            SettingTarget::Mode(mode) => Some(mode.as_str()),
438            _ => None,
439        };
440        self.path_parts
441            .iter()
442            .enumerate()
443            .map(|(index, part)| match part {
444                // An inherited `gamemodes.general` leaf occurs under the
445                // requested mode's group.
446                PathPart::Part("general")
447                    if index == 1 && self.inherited_general_key().is_some() && mode.is_some() =>
448                {
449                    mode.unwrap().to_string()
450                }
451                PathPart::Part(name) => (*name).to_string(),
452                PathPart::Team => target_team(target),
453                PathPart::Hero => target_hero(target),
454            })
455            .collect()
456    }
457}
458
459fn target_team(target: &SettingTarget) -> String {
460    match target {
461        SettingTarget::Team(team)
462        | SettingTarget::Hero {
463            team: Some(team), ..
464        }
465        | SettingTarget::TeamAbility {
466            team: Some(team), ..
467        }
468        | SettingTarget::HeroAbility {
469            team: Some(team), ..
470        } => team.as_str().to_string(),
471        _ => "allTeams".to_string(),
472    }
473}
474
475fn target_hero(target: &SettingTarget) -> String {
476    match target {
477        SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
478            hero.as_str().to_string()
479        }
480        _ => String::new(),
481    }
482}
483
484fn source_value_range(
485    source: &str,
486    span: crate::core::source::Span,
487    setting: &SettingId,
488) -> Result<Range<usize>, SettingOperationError> {
489    let start = crate::core::source::byte_offset(source, span.start).ok_or_else(|| {
490        SettingOperationError::SourceUnavailable {
491            setting: setting.clone(),
492        }
493    })?;
494    let end = crate::core::source::byte_offset(source, span.end).ok_or_else(|| {
495        SettingOperationError::SourceUnavailable {
496            setting: setting.clone(),
497        }
498    })?;
499    let member =
500        source
501            .get(start..end)
502            .ok_or_else(|| SettingOperationError::SourceUnavailable {
503                setting: setting.clone(),
504            })?;
505    let Some(colon) = member.find(':') else {
506        return Err(SettingOperationError::SourceUnavailable {
507            setting: setting.clone(),
508        });
509    };
510    let value_start = start + colon + 1;
511    let leading = source[value_start..end].len()
512        - source[value_start..end]
513            .trim_start_matches(char::is_whitespace)
514            .len();
515    let range = value_start + leading..end;
516    if range.is_empty() || source.get(range.clone()).is_none() {
517        return Err(SettingOperationError::SourceUnavailable {
518            setting: setting.clone(),
519        });
520    }
521    Ok(range)
522}
523
524fn source_value_spelling(
525    domain: &SettingValueDomain,
526    kind: KeyKind,
527    locale: &str,
528    setting: &SettingId,
529    value: SettingValue,
530) -> Result<String, SettingOperationError> {
531    let localized = |section: &str, english: &str| {
532        if locale.eq_ignore_ascii_case("en-US") {
533            Some(english)
534        } else {
535            table::localized_name(locale, section, english)
536        }
537        .map(str::to_string)
538        .ok_or_else(|| SettingOperationError::InvalidValue {
539            setting: setting.clone(),
540            message: format!("missing {section} locale mapping for '{english}' in {locale}"),
541            span: None,
542        })
543    };
544    match (domain, kind, value) {
545        (SettingValueDomain::Boolean, KeyKind::Bool, SettingValue::Boolean(value)) => {
546            localized("tokens", if value { "On" } else { "Off" })
547        }
548        (SettingValueDomain::Boolean, KeyKind::YesNo, SettingValue::Boolean(value)) => {
549            localized("tokens", if value { "Yes" } else { "No" })
550        }
551        (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => {
552            let english = table::enum_name(domain, "enabled").ok_or_else(|| {
553                SettingOperationError::InvalidValue {
554                    setting: setting.clone(),
555                    message: format!("unknown enabled member for enum domain '{domain}'"),
556                    span: None,
557                }
558            })?;
559            localized("enums", english)
560        }
561        (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
562            Err(SettingOperationError::InvalidValue {
563                setting: setting.clone(),
564                message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
565                span: None,
566            })
567        }
568        (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
569            Ok(crate::format::format_setting_number(value))
570        }
571        (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
572            Ok(format!("{}%", crate::format::format_setting_number(value)))
573        }
574        (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
575            "\"{}\"",
576            crate::output::emitter::escape_settings_string(&value)
577        )),
578        (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
579            let english = table::enum_name(domain, &member).ok_or_else(|| {
580                SettingOperationError::InvalidValue {
581                    setting: setting.clone(),
582                    message: format!("unknown member '{member}' for enum domain '{domain}'"),
583                    span: None,
584                }
585            })?;
586            localized("enums", english)
587        }
588        _ => Err(SettingOperationError::SourceUnavailable {
589            setting: setting.clone(),
590        }),
591    }
592}
593
594fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
595    let (name, rest) = path.split_first()?;
596    let node = children.iter().find(|node| node.name() == name)?;
597    if rest.is_empty() {
598        Some(node)
599    } else {
600        match node {
601            SettingsNode::Workshop { children, .. }
602            | SettingsNode::Group { children, .. }
603            | SettingsNode::RawGroup { children, .. } => find_node(children, rest),
604            _ => None,
605        }
606    }
607}
608
609fn find_node_mut<'a>(
610    children: &'a mut [SettingsNode],
611    path: &[String],
612) -> Option<&'a mut SettingsNode> {
613    let (name, rest) = path.split_first()?;
614    let node = children.iter_mut().find(|node| node.name() == name)?;
615    if rest.is_empty() {
616        Some(node)
617    } else {
618        match node {
619            SettingsNode::Workshop { children, .. }
620            | SettingsNode::Group { children, .. }
621            | SettingsNode::RawGroup { children, .. } => find_node_mut(children, rest),
622            _ => None,
623        }
624    }
625}
626
627fn validate_value(
628    domain: &SettingValueDomain,
629    id: &SettingId,
630    value: &SettingValue,
631    span: Option<crate::core::source::Span>,
632) -> Result<(), SettingOperationError> {
633    let expected = domain.kind();
634    if value.kind() != expected {
635        return Err(SettingOperationError::WrongValueKind {
636            setting: id.clone(),
637            expected,
638            actual: value.kind(),
639            span,
640        });
641    }
642    match (domain, value) {
643        (
644            SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
645            SettingValue::Number(value) | SettingValue::Percent(value),
646        ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
647            setting: id.clone(),
648            message: "numeric settings values must be finite".to_string(),
649            span,
650        }),
651        (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
652            if table::enum_name(domain, member).is_none() =>
653        {
654            Err(SettingOperationError::InvalidValue {
655                setting: id.clone(),
656                message: format!("unknown member '{member}' for enum domain '{domain}'"),
657                span,
658            })
659        }
660        (SettingValueDomain::HeroList, SettingValue::HeroList(values))
661            if values.iter().any(|value| table::hero_name(value).is_none()) =>
662        {
663            Err(SettingOperationError::InvalidValue {
664                setting: id.clone(),
665                message: "hero list contains an unknown hero".to_string(),
666                span,
667            })
668        }
669        (SettingValueDomain::MapList, SettingValue::MapList(values))
670            if values.iter().any(|value| table::map_name(value).is_none()) =>
671        {
672            Err(SettingOperationError::InvalidValue {
673                setting: id.clone(),
674                message: "map list contains an unknown map".to_string(),
675                span,
676            })
677        }
678        _ => Ok(()),
679    }
680}
681
682fn value_from_node(
683    node: &SettingsNode,
684    domain: &SettingValueDomain,
685    id: &SettingId,
686) -> Result<SettingValue, SettingOperationError> {
687    let value = match node {
688        SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
689        SettingsNode::Number { value, .. } => match domain {
690            SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
691            _ => SettingValue::Number(*value),
692        },
693        SettingsNode::String { value, .. } => match domain {
694            SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
695            _ => SettingValue::String(value.clone()),
696        },
697        SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
698        SettingsNode::List { elements, .. } => {
699            let values = elements
700                .iter()
701                .map(|element| element.value.clone())
702                .collect();
703            match domain {
704                SettingValueDomain::HeroList => SettingValue::HeroList(values),
705                _ => SettingValue::MapList(values),
706            }
707        }
708        _ => {
709            return Err(SettingOperationError::InvalidValue {
710                setting: id.clone(),
711                message: "settings occurrence is not a typed leaf".to_string(),
712                span: node.span(),
713            });
714        }
715    };
716    validate_value(domain, id, &value, node.span())?;
717    Ok(value)
718}
719
720fn apply_value(
721    node: &mut SettingsNode,
722    id: &SettingId,
723    value: SettingValue,
724) -> Result<(), SettingOperationError> {
725    match (node, value) {
726        (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
727            *current = value
728        }
729        (
730            SettingsNode::Number { value: current, .. },
731            SettingValue::Number(value) | SettingValue::Percent(value),
732        ) => *current = value,
733        (
734            SettingsNode::String { value: current, .. },
735            SettingValue::String(value) | SettingValue::Enum(value),
736        ) => *current = value,
737        (
738            SettingsNode::List { elements, span, .. },
739            SettingValue::HeroList(values) | SettingValue::MapList(values),
740        ) => {
741            if elements.len() != values.len() {
742                return Err(SettingOperationError::InvalidValue {
743                    setting: id.clone(),
744                    message: "source-preserving list edits cannot change list length".to_string(),
745                    span: *span,
746                });
747            }
748            elements
749                .iter_mut()
750                .zip(values)
751                .for_each(|(element, value)| element.value = value);
752        }
753        (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
754        (node, value) => {
755            return Err(SettingOperationError::WrongValueKind {
756                setting: id.clone(),
757                expected: "existing typed value",
758                actual: value.kind(),
759                span: node.span(),
760            });
761        }
762    }
763    Ok(())
764}
765
766fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
767    expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
768}
769
770fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
771    match target {
772        SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
773        _ => None,
774    }
775}
776
777fn hero_ability_exists(
778    hero: &HeroId,
779    slot: &LogicalSlot,
780    variant: Option<&AbilityVariant>,
781) -> Result<Option<bool>, GameplayDataError> {
782    data::builtin_ref().map_err(Clone::clone).map(|catalog| {
783        catalog.hero(hero).map(|hero| match variant {
784            Some(variant) => hero.ability_variant(slot, variant).is_ok(),
785            None => !hero.abilities_in_slot(slot).is_empty(),
786        })
787    })
788}