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 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>] {
57 self.path_parts
58 }
59
60 pub fn domain(&self) -> &SettingValueDomain {
61 &self.domain
62 }
63
64 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 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 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 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 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 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 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 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 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 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}