1use std::{fmt, ops::Range};
8
9use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot};
10use crate::gameplay::{GameplayDataError, data};
11
12use super::reconciliation;
13use super::table::{self, KeyKind, TableEntry};
14use super::{PathPart, Settings, SettingsNode};
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub struct SettingId(String);
19
20impl SettingId {
21 pub fn new(value: impl Into<String>) -> Self {
22 Self(value.into())
23 }
24
25 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28}
29
30impl From<&str> for SettingId {
31 fn from(value: &str) -> Self {
32 Self::new(value)
33 }
34}
35
36impl fmt::Display for SettingId {
37 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38 formatter.write_str(self.as_str())
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum SettingIdentity {
45 Known(SettingId),
46 Unknown,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum SettingScope {
52 Main,
53 Lobby,
54 GameModes,
55 Heroes,
56 Extensions,
57 Workshop,
58 Unknown,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
63pub struct TeamId(String);
64
65impl TeamId {
66 pub fn new(value: impl Into<String>) -> Self {
67 Self(value.into())
68 }
69
70 pub fn as_str(&self) -> &str {
71 &self.0
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub enum SettingTarget {
78 Global,
79 Mode(String),
80 Team(TeamId),
81 Hero {
82 team: Option<TeamId>,
83 hero: HeroId,
84 },
85 TeamAbility {
86 team: Option<TeamId>,
87 slot: LogicalSlot,
88 variant: Option<AbilityVariant>,
89 },
90 HeroAbility {
91 team: Option<TeamId>,
92 hero: HeroId,
93 slot: LogicalSlot,
94 variant: Option<AbilityVariant>,
95 },
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash)]
101pub enum SettingTargetKind {
102 Global,
103 Mode,
104 Team,
105 TeamAbility {
106 slot: LogicalSlot,
107 variant: Option<AbilityVariant>,
108 },
109 Hero,
110 HeroAbility {
111 slot: LogicalSlot,
112 variant: Option<AbilityVariant>,
113 },
114 Unknown,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Applicability {
120 Applicable,
121 NotApplicable,
122 Unknown,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum NumericBoundsError {
127 NonFinite,
128 Reversed,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
134pub struct NumericBounds {
135 min: Option<f64>,
136 max: Option<f64>,
137}
138
139impl NumericBounds {
140 pub const fn unknown() -> Self {
141 Self {
142 min: None,
143 max: None,
144 }
145 }
146
147 pub fn new(min: Option<f64>, max: Option<f64>) -> Result<Self, NumericBoundsError> {
148 if min.is_some_and(|value| !value.is_finite())
149 || max.is_some_and(|value| !value.is_finite())
150 {
151 return Err(NumericBoundsError::NonFinite);
152 }
153 if min.zip(max).is_some_and(|(min, max)| min > max) {
154 return Err(NumericBoundsError::Reversed);
155 }
156 Ok(Self { min, max })
157 }
158
159 pub fn min(&self) -> Option<f64> {
160 self.min
161 }
162
163 pub fn max(&self) -> Option<f64> {
164 self.max
165 }
166
167 pub fn effective(&self, authored: f64) -> Option<EffectiveNumber> {
168 if !authored.is_finite() || self.min.is_none() && self.max.is_none() {
169 return None;
170 }
171 match (self.min, self.max) {
172 (Some(min), None) if authored >= min => return None,
173 (None, Some(max)) if authored <= max => return None,
174 _ => {}
175 }
176 let mut effective = authored;
177 if let Some(min) = self.min {
178 effective = effective.max(min);
179 }
180 if let Some(max) = self.max {
181 effective = effective.min(max);
182 }
183 Some(EffectiveNumber {
184 authored,
185 effective,
186 })
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
192pub struct EffectiveNumber {
193 pub authored: f64,
194 pub effective: f64,
195}
196
197#[derive(Debug, Clone, PartialEq, PartialOrd)]
199pub enum SettingValueDomain {
200 Boolean,
201 Number(NumericBounds),
202 Percent(NumericBounds),
203 String,
204 Enum { domain: String },
205 HeroList,
206 MapList,
207 PresenceOnly,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub struct SettingEnumMember {
213 domain: &'static str,
214 id: &'static str,
215 english_name: &'static str,
216}
217
218impl SettingEnumMember {
219 pub fn domain(&self) -> &str {
220 self.domain
221 }
222
223 pub fn id(&self) -> &str {
224 self.id
225 }
226
227 pub fn english_name(&self) -> &str {
228 self.english_name
229 }
230}
231
232#[derive(Debug, Clone, PartialEq)]
234pub enum SettingValue {
235 Boolean(bool),
236 Number(f64),
237 Percent(f64),
238 String(String),
239 Enum(String),
240 HeroList(Vec<String>),
241 MapList(Vec<String>),
242 PresenceOnly,
243}
244
245#[derive(Debug, Clone, PartialEq)]
247pub struct SettingOccurrence {
248 pub authored: SettingValue,
249 pub effective: Option<EffectiveNumber>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct SettingSourceEdit {
258 range: Range<usize>,
259 expected: String,
260 replacement: String,
261}
262
263impl SettingSourceEdit {
264 pub fn range(&self) -> Range<usize> {
265 self.range.clone()
266 }
267
268 pub fn replacement(&self) -> &str {
269 &self.replacement
270 }
271
272 pub fn apply(&self, source: &str) -> Result<String, SettingOperationError> {
274 if source
275 .get(self.range.clone())
276 .is_none_or(|actual| actual != self.expected)
277 {
278 return Err(SettingOperationError::SourceMismatch);
279 }
280 let mut edited =
281 String::with_capacity(source.len() - self.expected.len() + self.replacement.len());
282 edited.push_str(&source[..self.range.start]);
283 edited.push_str(&self.replacement);
284 edited.push_str(&source[self.range.end..]);
285 Ok(edited)
286 }
287}
288
289#[derive(Debug, Clone, PartialEq)]
291pub enum SettingOperationError {
292 NotApplicable {
293 setting: SettingId,
294 target: SettingTarget,
295 },
296 NotFound {
297 setting: SettingId,
298 target: SettingTarget,
299 },
300 ApplicabilityUnknown {
301 setting: SettingId,
302 target: Box<SettingTarget>,
303 },
304 WrongValueKind {
305 setting: SettingId,
306 expected: &'static str,
307 actual: &'static str,
308 span: Option<crate::core::source::Span>,
309 },
310 InvalidValue {
311 setting: SettingId,
312 message: String,
313 span: Option<crate::core::source::Span>,
314 },
315 SourceUnavailable {
316 setting: SettingId,
317 },
318 SourceMismatch,
319}
320
321impl fmt::Display for SettingOperationError {
322 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323 match self {
324 Self::NotApplicable { setting, target } => {
325 write!(
326 formatter,
327 "setting {setting} does not apply to target {target:?}"
328 )
329 }
330 Self::NotFound { setting, target } => {
331 write!(
332 formatter,
333 "setting {setting} was not found for target {target:?}"
334 )
335 }
336 Self::ApplicabilityUnknown { setting, target } => write!(
337 formatter,
338 "applicability of setting {setting} is unknown for target {target:?}"
339 ),
340 Self::WrongValueKind {
341 setting,
342 expected,
343 actual,
344 ..
345 } => write!(
346 formatter,
347 "setting {setting} expects {expected} value, got {actual}"
348 ),
349 Self::InvalidValue {
350 setting, message, ..
351 } => write!(formatter, "invalid value for setting {setting}: {message}"),
352 Self::SourceUnavailable { setting } => {
353 write!(formatter, "setting {setting} has no editable source")
354 }
355 Self::SourceMismatch => formatter.write_str("source no longer matches the edit"),
356 }
357 }
358}
359
360impl std::error::Error for SettingOperationError {}
361
362impl SettingValueDomain {
363 pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
366 match self {
367 Self::Number(bounds) | Self::Percent(bounds) => bounds.effective(authored),
368 _ => None,
369 }
370 }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct SettingPresentation {
376 pub english_name: &'static str,
377 pub locale_section: &'static str,
378}
379
380impl SettingPresentation {
381 pub fn localized_name(&self, locale: &str) -> Option<&'static str> {
382 if locale.eq_ignore_ascii_case("en-US") {
383 Some(self.english_name)
384 } else {
385 table::localized_name(locale, self.locale_section, self.english_name)
386 }
387 }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub struct SettingSource {
393 pub kind: SettingSourceKind,
394 pub source: &'static str,
395 pub reviewed: bool,
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum SettingSourceKind {
400 RawWorkshopFixture,
401 WorkshopDataExport,
402}
403
404#[derive(Debug, Clone, PartialEq)]
406pub struct SettingDefinition {
407 identity: SettingIdentity,
408 scope: SettingScope,
409 path: String,
410 path_parts: &'static [PathPart<'static>],
411 key: &'static str,
412 target: TargetPattern,
413 domain: SettingValueDomain,
414 enum_domain: Option<&'static str>,
415 presentation: SettingPresentation,
416 source: SettingSource,
417}
418
419impl SettingDefinition {
420 pub fn identity(&self) -> &SettingIdentity {
421 &self.identity
422 }
423
424 pub fn id(&self) -> Option<&SettingId> {
425 match &self.identity {
426 SettingIdentity::Known(id) => Some(id),
427 SettingIdentity::Unknown => None,
428 }
429 }
430
431 pub fn scope(&self) -> SettingScope {
432 self.scope
433 }
434
435 pub fn path(&self) -> &str {
436 &self.path
437 }
438
439 pub fn domain(&self) -> &SettingValueDomain {
440 &self.domain
441 }
442
443 pub fn enum_members(&self) -> impl Iterator<Item = SettingEnumMember> + '_ {
447 self.enum_domain
448 .into_iter()
449 .flat_map(table::enum_members)
450 .map(|member| SettingEnumMember {
451 domain: member.domain,
452 id: member.member,
453 english_name: member.name,
454 })
455 }
456
457 pub fn target_kind(&self) -> SettingTargetKind {
458 match &self.target {
459 TargetPattern::Global => SettingTargetKind::Global,
460 TargetPattern::Mode(_) => SettingTargetKind::Mode,
461 TargetPattern::Team(_) => SettingTargetKind::Team,
462 TargetPattern::TeamAbility { slot, variant, .. } => SettingTargetKind::TeamAbility {
463 slot: slot.clone(),
464 variant: variant.clone(),
465 },
466 TargetPattern::Hero { .. } => SettingTargetKind::Hero,
467 TargetPattern::HeroAbility { slot, variant, .. } => SettingTargetKind::HeroAbility {
468 slot: slot.clone(),
469 variant: variant.clone(),
470 },
471 TargetPattern::Unknown => SettingTargetKind::Unknown,
472 }
473 }
474
475 pub fn presentation(&self) -> &SettingPresentation {
476 &self.presentation
477 }
478
479 pub fn localized_name(
480 &self,
481 locale: &str,
482 target: &SettingTarget,
483 ) -> Result<Option<&'static str>, GameplayDataError> {
484 match target {
485 SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
486 if self.applicability(target)? == Applicability::NotApplicable {
487 Ok(None)
488 } else {
489 Ok(table::hero_setting_name(hero.as_str(), self.key, locale)
490 .or_else(|| self.presentation.localized_name(locale)))
491 }
492 }
493 _ => Ok(self.presentation.localized_name(locale)),
494 }
495 }
496
497 pub fn source(&self) -> SettingSource {
498 self.source
499 }
500
501 pub fn applicability(
503 &self,
504 target: &SettingTarget,
505 ) -> Result<Applicability, GameplayDataError> {
506 Ok(match (&self.target, target) {
507 (TargetPattern::Global, SettingTarget::Global) => Applicability::Applicable,
508 (TargetPattern::Mode(expected), SettingTarget::Mode(actual)) => {
509 if expected
510 .as_deref()
511 .is_none_or(|expected| expected == actual)
512 {
513 Applicability::Applicable
514 } else {
515 Applicability::NotApplicable
516 }
517 }
518 (TargetPattern::Team(expected), SettingTarget::Team(actual)) => {
519 if expected
520 .as_deref()
521 .is_none_or(|expected| expected == actual.as_str())
522 {
523 Applicability::Applicable
524 } else {
525 Applicability::NotApplicable
526 }
527 }
528 (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => {
529 if team_matches(expected.as_deref(), team.as_ref()) {
530 Applicability::Unknown
531 } else {
532 Applicability::NotApplicable
533 }
534 }
535 (
536 TargetPattern::TeamAbility {
537 team,
538 slot,
539 variant: expected_variant,
540 },
541 SettingTarget::TeamAbility {
542 team: actual_team,
543 slot: actual_slot,
544 variant: actual_variant,
545 },
546 ) => {
547 if !team_matches(team.as_deref(), actual_team.as_ref())
548 || slot != actual_slot
549 || expected_variant
550 .as_ref()
551 .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
552 {
553 Applicability::NotApplicable
554 } else {
555 Applicability::Applicable
556 }
557 }
558 (
559 TargetPattern::TeamAbility {
560 team,
561 slot,
562 variant: expected_variant,
563 },
564 SettingTarget::HeroAbility {
565 team: actual_team,
566 hero: actual_hero,
567 slot: actual_slot,
568 variant: actual_variant,
569 },
570 ) => {
571 if !team_matches(team.as_deref(), actual_team.as_ref())
572 || slot != actual_slot
573 || expected_variant
574 .as_ref()
575 .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
576 {
577 Applicability::NotApplicable
578 } else {
579 match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? {
580 Some(true) => Applicability::Unknown,
581 Some(false) => Applicability::NotApplicable,
582 None => Applicability::Unknown,
583 }
584 }
585 }
586 (
587 TargetPattern::Hero { team, hero },
588 SettingTarget::Hero {
589 team: actual_team,
590 hero: actual_hero,
591 },
592 ) => {
593 if !team_matches(team.as_deref(), actual_team.as_ref())
594 || hero
595 .as_deref()
596 .is_some_and(|expected| expected != actual_hero.as_str())
597 {
598 Applicability::NotApplicable
599 } else {
600 Applicability::Unknown
601 }
602 }
603 (
604 TargetPattern::HeroAbility {
605 team,
606 hero,
607 slot,
608 variant: expected_variant,
609 },
610 SettingTarget::HeroAbility {
611 team: actual_team,
612 hero: actual_hero,
613 slot: actual_slot,
614 ..
615 },
616 ) => {
617 if !team_matches(team.as_deref(), actual_team.as_ref())
618 || hero
619 .as_deref()
620 .is_some_and(|expected| expected != actual_hero.as_str())
621 || slot.as_str() != actual_slot.as_str()
622 || expected_variant
623 .as_ref()
624 .is_some_and(|expected| Some(expected) != target_variant(target))
625 {
626 return Ok(Applicability::NotApplicable);
627 }
628 match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? {
629 None => Applicability::Unknown,
630 Some(false) => Applicability::NotApplicable,
631 Some(true) => Applicability::Unknown,
632 }
633 }
634 (TargetPattern::Unknown, _) => Applicability::Unknown,
635 _ => Applicability::NotApplicable,
636 })
637 }
638
639 pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
640 self.domain.effective_number(authored)
641 }
642
643 pub fn read(
646 &self,
647 settings: &Settings,
648 target: &SettingTarget,
649 ) -> Result<SettingOccurrence, SettingOperationError> {
650 let id = self.operation_id()?;
651 self.ensure_read_target(target)?;
652 let path = self.concrete_path(target);
653 let node = find_node(&settings.children, &path).ok_or_else(|| {
654 SettingOperationError::NotFound {
655 setting: id.clone(),
656 target: target.clone(),
657 }
658 })?;
659 let authored = value_from_node(node, &self.domain, &id)?;
660 let effective = match authored {
661 SettingValue::Number(value) | SettingValue::Percent(value) => {
662 self.effective_number(value)
663 }
664 _ => None,
665 };
666 Ok(SettingOccurrence {
667 authored,
668 effective,
669 })
670 }
671
672 pub fn write(
675 &self,
676 settings: &mut Settings,
677 target: &SettingTarget,
678 value: SettingValue,
679 ) -> Result<(), SettingOperationError> {
680 let id = self.operation_id()?;
681 self.ensure_write_target(target)?;
682 let path = self.concrete_path(target);
683 let node = find_node_mut(&mut settings.children, &path).ok_or_else(|| {
684 SettingOperationError::NotFound {
685 setting: id.clone(),
686 target: target.clone(),
687 }
688 })?;
689 let span = node.span();
690 validate_value(&self.domain, &id, &value, span)?;
691 apply_value(node, &id, value)
692 }
693
694 pub fn source_edit(
701 &self,
702 source: &str,
703 settings: &Settings,
704 locale: &str,
705 target: &SettingTarget,
706 value: SettingValue,
707 ) -> Result<SettingSourceEdit, SettingOperationError> {
708 let id = self.operation_id()?;
709 self.ensure_write_target(target)?;
710 let path = self.concrete_path(target);
711 let node = find_node(&settings.children, &path).ok_or_else(|| {
712 SettingOperationError::NotFound {
713 setting: id.clone(),
714 target: target.clone(),
715 }
716 })?;
717 validate_value(&self.domain, &id, &value, node.span())?;
718 let span = node
719 .span()
720 .ok_or_else(|| SettingOperationError::SourceUnavailable {
721 setting: id.clone(),
722 })?;
723 let range = source_value_range(source, span, &id)?;
724 let kind = table::lookup(self.path_parts)
725 .expect("settings definition must retain its table entry")
726 .kind;
727 let replacement = source_value_spelling(&self.domain, kind, locale, &id, value)?;
728 Ok(SettingSourceEdit {
729 expected: source[range.clone()].to_string(),
730 range,
731 replacement,
732 })
733 }
734
735 fn ensure_read_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
736 let id = self.operation_id()?;
737 match self
738 .applicability(target)
739 .map_err(|error| SettingOperationError::InvalidValue {
740 setting: id.clone(),
741 message: error.to_string(),
742 span: None,
743 })? {
744 Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
745 setting: id,
746 target: target.clone(),
747 }),
748 Applicability::Applicable | Applicability::Unknown => Ok(()),
749 }
750 }
751
752 fn ensure_write_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> {
753 let id = self.operation_id()?;
754 match self
755 .applicability(target)
756 .map_err(|error| SettingOperationError::InvalidValue {
757 setting: id.clone(),
758 message: error.to_string(),
759 span: None,
760 })? {
761 Applicability::NotApplicable => Err(SettingOperationError::NotApplicable {
762 setting: id,
763 target: target.clone(),
764 }),
765 Applicability::Unknown => Err(SettingOperationError::ApplicabilityUnknown {
766 setting: id,
767 target: Box::new(target.clone()),
768 }),
769 Applicability::Applicable => Ok(()),
770 }
771 }
772
773 fn operation_id(&self) -> Result<SettingId, SettingOperationError> {
774 self.id()
775 .cloned()
776 .ok_or_else(|| SettingOperationError::InvalidValue {
777 setting: SettingId::new("unknown"),
778 message: "setting has no reviewed canonical identity".to_string(),
779 span: None,
780 })
781 }
782
783 fn concrete_path(&self, target: &SettingTarget) -> Vec<String> {
784 self.path_parts
785 .iter()
786 .map(|part| match part {
787 PathPart::Part(name) => (*name).to_string(),
788 PathPart::Team => target_team(target),
789 PathPart::Hero => target_hero(target),
790 })
791 .collect()
792 }
793}
794
795fn target_team(target: &SettingTarget) -> String {
796 match target {
797 SettingTarget::Team(team)
798 | SettingTarget::Hero {
799 team: Some(team), ..
800 }
801 | SettingTarget::TeamAbility {
802 team: Some(team), ..
803 }
804 | SettingTarget::HeroAbility {
805 team: Some(team), ..
806 } => team.as_str().to_string(),
807 _ => "allTeams".to_string(),
808 }
809}
810
811fn target_hero(target: &SettingTarget) -> String {
812 match target {
813 SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
814 hero.as_str().to_string()
815 }
816 _ => String::new(),
817 }
818}
819
820fn source_value_range(
821 source: &str,
822 span: crate::core::source::Span,
823 setting: &SettingId,
824) -> Result<Range<usize>, SettingOperationError> {
825 let start = byte_offset(source, span.start).ok_or_else(|| {
826 SettingOperationError::SourceUnavailable {
827 setting: setting.clone(),
828 }
829 })?;
830 let end =
831 byte_offset(source, span.end).ok_or_else(|| SettingOperationError::SourceUnavailable {
832 setting: setting.clone(),
833 })?;
834 let member =
835 source
836 .get(start..end)
837 .ok_or_else(|| SettingOperationError::SourceUnavailable {
838 setting: setting.clone(),
839 })?;
840 let Some(colon) = member.find(':') else {
841 return Err(SettingOperationError::SourceUnavailable {
842 setting: setting.clone(),
843 });
844 };
845 let value_start = start + colon + 1;
846 let leading = source[value_start..end].len()
847 - source[value_start..end]
848 .trim_start_matches(char::is_whitespace)
849 .len();
850 let range = value_start + leading..end;
851 if range.is_empty() || source.get(range.clone()).is_none() {
852 return Err(SettingOperationError::SourceUnavailable {
853 setting: setting.clone(),
854 });
855 }
856 Ok(range)
857}
858
859fn byte_offset(source: &str, position: crate::core::source::Position) -> Option<usize> {
860 if !position.is_valid() {
861 return None;
862 }
863 let mut line = 1;
864 let mut col = 1;
865 for (index, character) in source.char_indices() {
866 if line == position.line && col == position.col {
867 return Some(index);
868 }
869 if character == '\n' {
870 line += 1;
871 col = 1;
872 } else {
873 col += 1;
874 }
875 }
876 (line == position.line && col == position.col).then_some(source.len())
877}
878
879fn source_value_spelling(
880 domain: &SettingValueDomain,
881 kind: KeyKind,
882 locale: &str,
883 setting: &SettingId,
884 value: SettingValue,
885) -> Result<String, SettingOperationError> {
886 let localized = |section: &str, english: &str| {
887 if locale.eq_ignore_ascii_case("en-US") {
888 Some(english)
889 } else {
890 table::localized_name(locale, section, english)
891 }
892 .map(str::to_string)
893 .ok_or_else(|| SettingOperationError::InvalidValue {
894 setting: setting.clone(),
895 message: format!("missing {section} locale mapping for '{english}' in {locale}"),
896 span: None,
897 })
898 };
899 match (domain, kind, value) {
900 (SettingValueDomain::Boolean, KeyKind::Bool, SettingValue::Boolean(value)) => {
901 localized("tokens", if value { "On" } else { "Off" })
902 }
903 (SettingValueDomain::Boolean, KeyKind::YesNo, SettingValue::Boolean(value)) => {
904 localized("tokens", if value { "Yes" } else { "No" })
905 }
906 (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => {
907 let english = table::enum_name(domain, "enabled").ok_or_else(|| {
908 SettingOperationError::InvalidValue {
909 setting: setting.clone(),
910 message: format!("unknown enabled member for enum domain '{domain}'"),
911 span: None,
912 }
913 })?;
914 localized("enums", english)
915 }
916 (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
917 Err(SettingOperationError::InvalidValue {
918 setting: setting.clone(),
919 message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
920 span: None,
921 })
922 }
923 (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
924 Ok(crate::format::format_setting_number(value))
925 }
926 (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
927 Ok(format!("{}%", crate::format::format_setting_number(value)))
928 }
929 (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
930 "\"{}\"",
931 crate::output::emitter::escape_settings_string(&value)
932 )),
933 (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
934 let english = table::enum_name(domain, &member).ok_or_else(|| {
935 SettingOperationError::InvalidValue {
936 setting: setting.clone(),
937 message: format!("unknown member '{member}' for enum domain '{domain}'"),
938 span: None,
939 }
940 })?;
941 localized("enums", english)
942 }
943 _ => Err(SettingOperationError::SourceUnavailable {
944 setting: setting.clone(),
945 }),
946 }
947}
948
949fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
950 let (name, rest) = path.split_first()?;
951 let node = children.iter().find(|node| node.name() == name)?;
952 if rest.is_empty() {
953 Some(node)
954 } else {
955 match node {
956 SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
957 find_node(children, rest)
958 }
959 _ => None,
960 }
961 }
962}
963
964fn find_node_mut<'a>(
965 children: &'a mut [SettingsNode],
966 path: &[String],
967) -> Option<&'a mut SettingsNode> {
968 let (name, rest) = path.split_first()?;
969 let node = children.iter_mut().find(|node| node.name() == name)?;
970 if rest.is_empty() {
971 Some(node)
972 } else {
973 match node {
974 SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
975 find_node_mut(children, rest)
976 }
977 _ => None,
978 }
979 }
980}
981
982fn value_kind(value: &SettingValue) -> &'static str {
983 match value {
984 SettingValue::Boolean(_) => "boolean",
985 SettingValue::Number(_) => "number",
986 SettingValue::Percent(_) => "percent",
987 SettingValue::String(_) => "string",
988 SettingValue::Enum(_) => "enum",
989 SettingValue::HeroList(_) => "hero-list",
990 SettingValue::MapList(_) => "map-list",
991 SettingValue::PresenceOnly => "presence-only",
992 }
993}
994
995fn domain_kind(domain: &SettingValueDomain) -> &'static str {
996 match domain {
997 SettingValueDomain::Boolean => "boolean",
998 SettingValueDomain::Number(_) => "number",
999 SettingValueDomain::Percent(_) => "percent",
1000 SettingValueDomain::String => "string",
1001 SettingValueDomain::Enum { .. } => "enum",
1002 SettingValueDomain::HeroList => "hero-list",
1003 SettingValueDomain::MapList => "map-list",
1004 SettingValueDomain::PresenceOnly => "presence-only",
1005 }
1006}
1007
1008fn validate_value(
1009 domain: &SettingValueDomain,
1010 id: &SettingId,
1011 value: &SettingValue,
1012 span: Option<crate::core::source::Span>,
1013) -> Result<(), SettingOperationError> {
1014 let expected = domain_kind(domain);
1015 if value_kind(value) != expected {
1016 return Err(SettingOperationError::WrongValueKind {
1017 setting: id.clone(),
1018 expected,
1019 actual: value_kind(value),
1020 span,
1021 });
1022 }
1023 match (domain, value) {
1024 (
1025 SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
1026 SettingValue::Number(value) | SettingValue::Percent(value),
1027 ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
1028 setting: id.clone(),
1029 message: "numeric settings values must be finite".to_string(),
1030 span,
1031 }),
1032 (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
1033 if table::enum_name(domain, member).is_none() =>
1034 {
1035 Err(SettingOperationError::InvalidValue {
1036 setting: id.clone(),
1037 message: format!("unknown member '{member}' for enum domain '{domain}'"),
1038 span,
1039 })
1040 }
1041 (SettingValueDomain::HeroList, SettingValue::HeroList(values))
1042 if values.iter().any(|value| table::hero_name(value).is_none()) =>
1043 {
1044 Err(SettingOperationError::InvalidValue {
1045 setting: id.clone(),
1046 message: "hero list contains an unknown hero".to_string(),
1047 span,
1048 })
1049 }
1050 (SettingValueDomain::MapList, SettingValue::MapList(values))
1051 if values.iter().any(|value| table::map_name(value).is_none()) =>
1052 {
1053 Err(SettingOperationError::InvalidValue {
1054 setting: id.clone(),
1055 message: "map list contains an unknown map".to_string(),
1056 span,
1057 })
1058 }
1059 _ => Ok(()),
1060 }
1061}
1062
1063fn value_from_node(
1064 node: &SettingsNode,
1065 domain: &SettingValueDomain,
1066 id: &SettingId,
1067) -> Result<SettingValue, SettingOperationError> {
1068 let value = match node {
1069 SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
1070 SettingsNode::Number { value, .. } => match domain {
1071 SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
1072 _ => SettingValue::Number(*value),
1073 },
1074 SettingsNode::String { value, .. } => match domain {
1075 SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
1076 _ => SettingValue::String(value.clone()),
1077 },
1078 SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
1079 SettingsNode::List { elements, .. } => {
1080 let values = elements
1081 .iter()
1082 .map(|element| element.value.clone())
1083 .collect();
1084 match domain {
1085 SettingValueDomain::HeroList => SettingValue::HeroList(values),
1086 _ => SettingValue::MapList(values),
1087 }
1088 }
1089 _ => {
1090 return Err(SettingOperationError::InvalidValue {
1091 setting: id.clone(),
1092 message: "settings occurrence is not a typed leaf".to_string(),
1093 span: node.span(),
1094 });
1095 }
1096 };
1097 validate_value(domain, id, &value, node.span())?;
1098 Ok(value)
1099}
1100
1101fn apply_value(
1102 node: &mut SettingsNode,
1103 id: &SettingId,
1104 value: SettingValue,
1105) -> Result<(), SettingOperationError> {
1106 match (node, value) {
1107 (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
1108 *current = value
1109 }
1110 (
1111 SettingsNode::Number { value: current, .. },
1112 SettingValue::Number(value) | SettingValue::Percent(value),
1113 ) => *current = value,
1114 (
1115 SettingsNode::String { value: current, .. },
1116 SettingValue::String(value) | SettingValue::Enum(value),
1117 ) => *current = value,
1118 (
1119 SettingsNode::List { elements, span, .. },
1120 SettingValue::HeroList(values) | SettingValue::MapList(values),
1121 ) => {
1122 if elements.len() != values.len() {
1123 return Err(SettingOperationError::InvalidValue {
1124 setting: id.clone(),
1125 message: "source-preserving list edits cannot change list length".to_string(),
1126 span: *span,
1127 });
1128 }
1129 elements
1130 .iter_mut()
1131 .zip(values)
1132 .for_each(|(element, value)| element.value = value);
1133 }
1134 (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
1135 (node, value) => {
1136 return Err(SettingOperationError::WrongValueKind {
1137 setting: id.clone(),
1138 expected: "existing typed value",
1139 actual: value_kind(&value),
1140 span: node.span(),
1141 });
1142 }
1143 }
1144 Ok(())
1145}
1146
1147#[derive(Debug, Clone, PartialEq)]
1148enum TargetPattern {
1149 Global,
1150 Mode(Option<String>),
1151 Team(Option<String>),
1152 TeamAbility {
1153 team: Option<String>,
1154 slot: LogicalSlot,
1155 variant: Option<AbilityVariant>,
1156 },
1157 Hero {
1158 team: Option<String>,
1159 hero: Option<String>,
1160 },
1161 HeroAbility {
1162 team: Option<String>,
1163 hero: Option<String>,
1164 slot: LogicalSlot,
1165 variant: Option<AbilityVariant>,
1166 },
1167 Unknown,
1168}
1169
1170fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
1171 expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
1172}
1173
1174fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
1175 match target {
1176 SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
1177 _ => None,
1178 }
1179}
1180
1181fn hero_ability_exists(
1182 hero: &HeroId,
1183 slot: &LogicalSlot,
1184 variant: Option<&AbilityVariant>,
1185) -> Result<Option<bool>, GameplayDataError> {
1186 data::builtin_ref().map_err(Clone::clone).map(|catalog| {
1187 catalog.hero(hero).map(|hero| match variant {
1188 Some(variant) => hero.ability_variant(slot, variant).is_ok(),
1189 None => !hero.abilities_in_slot(slot).is_empty(),
1190 })
1191 })
1192}
1193
1194pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
1199 table::entries().map(SettingDefinition::from_entry)
1200}
1201
1202pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
1204 table::lookup(path).map(SettingDefinition::from_entry)
1205}
1206
1207pub fn definitions_by_id(id: &SettingId) -> impl Iterator<Item = SettingDefinition> {
1214 definitions().filter(move |definition| definition.id() == Some(id))
1215}
1216
1217impl SettingDefinition {
1218 fn from_entry(entry: &TableEntry) -> Self {
1219 let scope = scope_for(entry.path);
1220 let key = entry
1221 .path
1222 .last()
1223 .and_then(|part| match part {
1224 PathPart::Part(key) => Some(*key),
1225 _ => None,
1226 })
1227 .unwrap_or("");
1228 let target = target_for(entry.path);
1229 let path = table::path_string(entry.path);
1230 let domain = domain_for(entry.kind);
1231 let identity = canonical_id(scope, key, entry.path)
1232 .map(SettingIdentity::Known)
1233 .unwrap_or(SettingIdentity::Unknown);
1234 Self {
1235 identity,
1236 scope,
1237 path,
1238 path_parts: entry.path,
1239 key,
1240 target,
1241 domain,
1242 enum_domain: match entry.kind {
1243 KeyKind::BoolEnum(domain) | KeyKind::Enum(domain) => Some(domain),
1244 _ => None,
1245 },
1246 presentation: SettingPresentation {
1247 english_name: entry.workshop_name,
1248 locale_section: "labels",
1249 },
1250 source: SettingSource {
1251 kind: if table::is_generated_entry(entry) {
1252 SettingSourceKind::WorkshopDataExport
1253 } else {
1254 SettingSourceKind::RawWorkshopFixture
1255 },
1256 source: if table::is_generated_entry(entry) {
1257 "workshop-data/workshop-data.json"
1258 } else {
1259 "pinned raw Workshop settings fixtures"
1260 },
1261 reviewed: true,
1262 },
1263 }
1264 }
1265}
1266
1267fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
1268 match path.first() {
1269 Some(PathPart::Part("main")) => SettingScope::Main,
1270 Some(PathPart::Part("lobby")) => SettingScope::Lobby,
1271 Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
1272 Some(PathPart::Part("heroes")) => SettingScope::Heroes,
1273 Some(PathPart::Part("extensions")) => SettingScope::Extensions,
1274 Some(PathPart::Part("workshop")) => SettingScope::Workshop,
1275 _ => SettingScope::Unknown,
1276 }
1277}
1278
1279fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
1280 match path {
1281 [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
1282 [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
1283 TargetPattern::Mode(Some((*mode).to_string()))
1284 }
1285 [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
1286 [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
1287 target_for_hero(path, None)
1288 }
1289 [
1290 PathPart::Part("heroes"),
1291 PathPart::Part(team),
1292 PathPart::Hero,
1293 ..,
1294 ] => target_for_hero(path, Some((*team).to_string())),
1295 [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
1296 [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
1297 target_for_team(path, Some((*team).to_string()))
1298 }
1299 [
1300 PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
1301 ..,
1302 ] => TargetPattern::Global,
1303 _ => TargetPattern::Unknown,
1304 }
1305}
1306
1307fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1308 match semantic_ability_slot_for_path(path) {
1309 Some(slot) => TargetPattern::TeamAbility {
1310 team,
1311 slot: LogicalSlot::new(slot),
1312 variant: None,
1313 },
1314 None => TargetPattern::Team(team),
1315 }
1316}
1317
1318fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1319 let slot = semantic_ability_slot_for_path(path).map(str::to_string);
1320 match slot {
1321 Some(slot) => TargetPattern::HeroAbility {
1322 team,
1323 hero: None,
1324 slot: LogicalSlot::new(slot),
1325 variant: None,
1326 },
1327 None => TargetPattern::Hero { team, hero: None },
1328 }
1329}
1330
1331fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
1332 match path.last() {
1333 Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
1334 Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
1335 Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
1336 Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
1337 Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
1338 Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
1339 Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
1340 Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
1341 _ => table::ability_slot_for_path(path),
1342 }
1343}
1344
1345fn domain_for(kind: KeyKind) -> SettingValueDomain {
1346 match kind {
1347 KeyKind::Flag => SettingValueDomain::PresenceOnly,
1348 KeyKind::String => SettingValueDomain::String,
1349 KeyKind::Bool | KeyKind::YesNo | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean,
1350 KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
1351 KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
1352 KeyKind::Enum(domain) => SettingValueDomain::Enum {
1353 domain: domain.to_string(),
1354 },
1355 KeyKind::ListMap => SettingValueDomain::MapList,
1356 KeyKind::ListHero => SettingValueDomain::HeroList,
1357 }
1358}
1359
1360fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
1361 let prefix = match scope {
1362 SettingScope::Main => "main",
1363 SettingScope::Lobby => "lobby",
1364 SettingScope::GameModes => "gameMode",
1365 SettingScope::Heroes => "hero",
1366 SettingScope::Extensions => "extension",
1367 SettingScope::Workshop => "workshop",
1368 SettingScope::Unknown => "unknown",
1369 };
1370 if matches!(scope, SettingScope::Unknown) {
1371 return None;
1372 }
1373 let concept = canonical_concept(key, path)?;
1374 Some(SettingId::new(format!("setting.{prefix}.{concept}")))
1375}
1376
1377fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option<String> {
1381 let key = key.trim_end_matches('%');
1382 Some(match key {
1383 "health" => "health".to_string(),
1384 "damageDealt" | "damageReceived" | "healingDealt" | "healingReceived" => key.to_string(),
1385 "passiveUltGen" => "ultimateGeneration.passive".to_string(),
1386 "combatUltGen" => "ultimateGeneration.combat".to_string(),
1387 "ultGen" => "ultimateGeneration".to_string(),
1388 "enableUlt" => "ability.enabled".to_string(),
1389 "enablePrimaryFire"
1390 | "enableSecondaryFire"
1391 | "enableGenericSecondaryFire"
1392 | "enableAbility1"
1393 | "enableAbility2"
1394 | "enableAbility3" => "ability.enabled".to_string(),
1395 "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(),
1396 "enableScoping" => "primaryFire.scopingEnabled".to_string(),
1397 "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(),
1398 "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(),
1399 "setValidControlPoints" | "firstActiveControlPoint" => path
1400 .iter()
1401 .filter_map(|part| match part {
1402 PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name),
1403 _ => None,
1404 })
1405 .next()
1406 .map(|mode| format!("{key}.{mode}"))?,
1407 _ => key.to_string(),
1408 })
1409}
1410
1411pub fn validate_catalog() -> Result<(), Vec<String>> {
1414 use std::collections::{HashMap, HashSet};
1415
1416 let mut errors = Vec::new();
1417 errors.extend(reconciliation::validate());
1418 errors.extend(validate_raw_projection(table::raw_entries()));
1419 errors.extend(validate_enum_projection(
1420 table::ENUM_MEMBERS.iter(),
1421 table::GENERATED_ENUM_MEMBERS.iter(),
1422 &reconciliation::data().enum_member_mappings,
1423 ));
1424 let mut paths = HashSet::new();
1425 let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> =
1426 HashMap::new();
1427 let mut concept_keys: HashMap<(String, SettingTargetKind), String> = HashMap::new();
1428
1429 for definition in definitions() {
1430 if !paths.insert(definition.path.clone()) {
1431 errors.push(format!("duplicate settings path: {}", definition.path));
1432 }
1433 if definition.scope == SettingScope::Unknown {
1434 errors.push(format!("unknown settings scope: {}", definition.path));
1435 }
1436 let Some(id) = definition.id() else {
1437 errors.push(format!(
1438 "missing canonical settings identity: {}",
1439 definition.path
1440 ));
1441 continue;
1442 };
1443 if !definition.source.reviewed {
1444 errors.push(format!(
1445 "unreviewed settings definition: {}",
1446 definition.path
1447 ));
1448 }
1449 if definition.presentation.english_name.is_empty() {
1450 errors.push(format!(
1451 "missing settings presentation: {}",
1452 definition.path
1453 ));
1454 }
1455 let target_kind = definition.target_kind();
1456 let semantic_key = semantic_identity_key(definition.key);
1457 let collision_key = (id.as_str().to_string(), target_kind.clone());
1458 if let Some(previous_key) = concept_keys.insert(collision_key, semantic_key.clone()) {
1459 if previous_key != semantic_key {
1460 errors.push(format!(
1461 "conflicting settings concepts for {id}: {previous_key} vs {semantic_key}"
1462 ));
1463 }
1464 }
1465 let key = (id.as_str().to_string(), target_kind, semantic_key);
1466 if let Some(previous) = concepts.insert(key, definition.domain.clone()) {
1467 if previous != definition.domain {
1468 errors.push(format!("conflicting settings domains for {id}"));
1469 }
1470 }
1471 }
1472 if errors.is_empty() {
1473 Ok(())
1474 } else {
1475 Err(errors)
1476 }
1477}
1478
1479fn validate_raw_projection(
1483 entries: impl IntoIterator<Item = table::ProjectedEntry>,
1484) -> Vec<String> {
1485 use std::collections::HashMap;
1486
1487 let mut errors = Vec::new();
1488 let mut paths = HashMap::new();
1489 for projected in entries {
1490 let entry = projected.entry;
1491 if let Some(previous) = paths.insert(entry.path, projected) {
1492 if previous.entry != entry
1493 && !reconciled_entry_override(
1494 table::path_string(entry.path).as_str(),
1495 previous,
1496 projected,
1497 )
1498 {
1499 errors.push(format!(
1500 "conflicting duplicate settings path between {} and {}: {}",
1501 previous.source.label(),
1502 projected.source.label(),
1503 table::path_string(entry.path),
1504 ));
1505 }
1506 }
1507 }
1508 errors
1509}
1510
1511fn reconciled_entry_override(
1512 path: &str,
1513 fixture: table::ProjectedEntry,
1514 generated: table::ProjectedEntry,
1515) -> bool {
1516 use table::ProjectionSource::{FixtureTable, WorkshopDataExport};
1517
1518 let (fixture, generated) = match (fixture.source, generated.source) {
1519 (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry),
1520 (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry),
1521 _ => return false,
1522 };
1523 reconciliation::data()
1524 .entry_overrides
1525 .iter()
1526 .find(|override_| override_.path == path)
1527 .is_some_and(|override_| {
1528 entry_contract_matches(fixture, &override_.fixture)
1529 && entry_contract_matches(generated, &override_.generated)
1530 })
1531}
1532
1533fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool {
1534 entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected)
1535}
1536
1537fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool {
1538 match (kind, expected.kind.as_str(), expected.domain.as_deref()) {
1539 (KeyKind::Flag, "flag", None)
1540 | (KeyKind::String, "string", None)
1541 | (KeyKind::Bool, "bool", None)
1542 | (KeyKind::YesNo, "yesNo", None)
1543 | (KeyKind::Number, "number", None)
1544 | (KeyKind::Percent, "percent", None)
1545 | (KeyKind::ListMap, "mapList", None)
1546 | (KeyKind::ListHero, "heroList", None) => true,
1547 (KeyKind::BoolEnum(actual), "boolEnum", Some(expected)) => actual == expected,
1548 (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected,
1549 _ => false,
1550 }
1551}
1552
1553fn validate_enum_projection(
1557 fixture_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1558 generated_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1559 mappings: &[reconciliation::EnumMemberMapping],
1560) -> Vec<String> {
1561 use std::collections::{HashMap, HashSet};
1562
1563 let domains: HashSet<_> = table::entries()
1564 .filter_map(|entry| match entry.kind {
1565 KeyKind::Enum(domain) | KeyKind::BoolEnum(domain) => Some(domain),
1566 _ => None,
1567 })
1568 .collect();
1569 let mut errors = Vec::new();
1570 let mut members = HashMap::new();
1571 let mut names = HashMap::new();
1572 for member in fixture_entries {
1573 if !domains.contains(member.domain) {
1574 errors.push(format!("orphaned settings enum domain: {}", member.domain));
1575 }
1576 let key = (member.domain, member.member);
1577 if let Some(previous) = members.insert(key, member.name) {
1578 if previous != member.name {
1579 errors.push(format!(
1580 "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1581 member.domain, member.member, member.name
1582 ));
1583 }
1584 }
1585 if let Some(previous) = names.insert((member.domain, member.name), member.member) {
1586 if previous != member.member {
1587 errors.push(format!(
1588 "conflicting settings enum display name {}.{:?}: {previous} vs {}",
1589 member.domain, member.name, member.member
1590 ));
1591 }
1592 }
1593 }
1594 let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS
1595 .iter()
1596 .map(|member| ((member.domain, member.member), member))
1597 .collect();
1598 let mut mapped_sources = HashSet::new();
1599 for member in generated_entries {
1600 let key = (member.domain, member.member);
1601 if let Some(previous) = members.insert(key, member.name) {
1602 if previous != member.name {
1603 errors.push(format!(
1604 "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1605 member.domain, member.member, member.name
1606 ));
1607 }
1608 }
1609 let mapping = mappings.iter().find(|mapping| {
1610 mapping.source_domain == member.domain && mapping.source_member == member.member
1611 });
1612 if mapping.is_none() && !domains.contains(member.domain) {
1613 errors.push(format!("orphaned settings enum domain: {}", member.domain));
1614 }
1615 let (domain, canonical_member, name) = match mapping {
1616 Some(mapping) => {
1617 if !mapped_sources.insert((
1618 mapping.source_domain.as_str(),
1619 mapping.source_member.as_str(),
1620 )) {
1621 errors.push(format!(
1622 "duplicate settings enum reconciliation for {}.{}",
1623 mapping.source_domain, mapping.source_member
1624 ));
1625 }
1626 match fixture_members.get(&(
1627 mapping.target_domain.as_str(),
1628 mapping.target_member.as_str(),
1629 )) {
1630 Some(target) if target.name == member.name => {
1631 (target.domain, target.member, target.name)
1632 }
1633 Some(target) => {
1634 errors.push(format!(
1635 "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}",
1636 mapping.source_domain, mapping.source_member,
1637 mapping.target_domain, mapping.target_member, member.name, target.name
1638 ));
1639 continue;
1640 }
1641 None => {
1642 errors.push(format!(
1643 "settings enum reconciliation target is missing: {}.{} -> {}.{}",
1644 mapping.source_domain,
1645 mapping.source_member,
1646 mapping.target_domain,
1647 mapping.target_member
1648 ));
1649 continue;
1650 }
1651 }
1652 }
1653 None => (member.domain, member.member, member.name),
1654 };
1655 if let Some(previous) = names.insert((domain, name), canonical_member) {
1656 if previous != canonical_member {
1657 errors.push(format!(
1658 "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}",
1659 domain
1660 ));
1661 }
1662 }
1663 }
1664 for mapping in mappings {
1665 if !mapped_sources.contains(&(
1666 mapping.source_domain.as_str(),
1667 mapping.source_member.as_str(),
1668 )) {
1669 errors.push(format!(
1670 "orphaned settings enum reconciliation: {}.{}",
1671 mapping.source_domain, mapping.source_member
1672 ));
1673 }
1674 }
1675 errors
1676}
1677
1678fn semantic_identity_key(key: &str) -> String {
1679 match key {
1680 "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(),
1681 _ => key.to_string(),
1682 }
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687 use super::*;
1688
1689 static DUPLICATE_PATH: [PathPart<'static>; 2] =
1690 [PathPart::Part("test"), PathPart::Part("value")];
1691 static FIXTURE_ENTRY: TableEntry = TableEntry {
1692 path: &DUPLICATE_PATH,
1693 workshop_name: "Fixture Value",
1694 kind: KeyKind::Bool,
1695 };
1696 static GENERATED_ENTRY: TableEntry = TableEntry {
1697 path: &DUPLICATE_PATH,
1698 workshop_name: "Generated Value",
1699 kind: KeyKind::Bool,
1700 };
1701 static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1702 domain: "mapRotation",
1703 member: "afterAGame",
1704 name: "After A Game",
1705 };
1706 static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1707 domain: "mapRotation",
1708 member: "afterAGame",
1709 name: "After Game",
1710 };
1711 static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember {
1712 domain: "mapRotation",
1713 member: "afterMirrorMatch",
1714 name: "After A Game",
1715 };
1716 static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1717 domain: "setting_lobby_mapRotation",
1718 member: "afterGame",
1719 name: "After A Game",
1720 };
1721
1722 fn definition(target: TargetPattern) -> SettingDefinition {
1723 SettingDefinition {
1724 identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
1725 scope: SettingScope::Heroes,
1726 path: "heroes.test.value".to_string(),
1727 path_parts: &[],
1728 key: "value",
1729 target,
1730 domain: SettingValueDomain::Boolean,
1731 enum_domain: None,
1732 presentation: SettingPresentation {
1733 english_name: "Value",
1734 locale_section: "labels",
1735 },
1736 source: SettingSource {
1737 kind: SettingSourceKind::RawWorkshopFixture,
1738 source: "test",
1739 reviewed: true,
1740 },
1741 }
1742 }
1743
1744 #[test]
1745 fn common_target_narrowing_rejects_team_and_slot_mismatches() {
1746 let team = definition(TargetPattern::Team(Some("team1".to_string())));
1747 assert_eq!(
1748 team.applicability(&SettingTarget::Hero {
1749 team: Some(TeamId::new("team2")),
1750 hero: HeroId::from(crate::gameplay::hero_ids::ANA),
1751 })
1752 .expect("applicability"),
1753 Applicability::NotApplicable
1754 );
1755
1756 let team_ability = definition(TargetPattern::TeamAbility {
1757 team: Some("team1".to_string()),
1758 slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
1759 variant: None,
1760 });
1761 let target = SettingTarget::HeroAbility {
1762 team: Some(TeamId::new("team2")),
1763 hero: HeroId::from(crate::gameplay::hero_ids::DVA),
1764 slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
1765 variant: Some(AbilityVariant::new("mech")),
1766 };
1767 assert_eq!(
1768 team_ability.applicability(&target).expect("applicability"),
1769 Applicability::NotApplicable
1770 );
1771 }
1772
1773 #[test]
1774 fn raw_projection_conflicts_include_presentation_contract() {
1775 let errors = validate_raw_projection([
1776 table::ProjectedEntry {
1777 source: table::ProjectionSource::FixtureTable,
1778 entry: &FIXTURE_ENTRY,
1779 },
1780 table::ProjectedEntry {
1781 source: table::ProjectionSource::WorkshopDataExport,
1782 entry: &GENERATED_ENTRY,
1783 },
1784 ]);
1785 assert_eq!(errors.len(), 1);
1786 assert!(errors[0].contains("fixture table"));
1787 assert!(errors[0].contains("Workshop-data export"));
1788 }
1789
1790 #[test]
1791 fn enum_projection_conflicts_are_not_hidden_by_lookup_order() {
1792 let errors =
1793 validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]);
1794 assert_eq!(errors.len(), 1);
1795 assert!(errors[0].contains("mapRotation.afterAGame"));
1796 }
1797
1798 #[test]
1799 fn enum_projection_rejects_display_name_to_identity_collisions() {
1800 let errors =
1801 validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]);
1802 assert_eq!(errors.len(), 1);
1803 assert!(errors[0].contains("conflicting settings enum display name"));
1804 }
1805
1806 #[test]
1807 fn enum_projection_reconciles_export_members_to_canonical_identities() {
1808 let mappings = [reconciliation::EnumMemberMapping {
1809 source_domain: "setting_lobby_mapRotation".to_string(),
1810 source_member: "afterGame".to_string(),
1811 target_domain: "mapRotation".to_string(),
1812 target_member: "afterAGame".to_string(),
1813 }];
1814 let errors =
1815 validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings);
1816 assert!(errors.is_empty(), "{errors:?}");
1817 }
1818}