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