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::BoolEnum(domain), SettingValue::Boolean(true)) => {
904 let english = table::enum_name(domain, "enabled").ok_or_else(|| {
905 SettingOperationError::InvalidValue {
906 setting: setting.clone(),
907 message: format!("unknown enabled member for enum domain '{domain}'"),
908 span: None,
909 }
910 })?;
911 localized("enums", english)
912 }
913 (SettingValueDomain::Boolean, KeyKind::BoolEnum(_), SettingValue::Boolean(false)) => {
914 Err(SettingOperationError::InvalidValue {
915 setting: setting.clone(),
916 message: "false is unsupported by this Workshop boolean-enum setting".to_string(),
917 span: None,
918 })
919 }
920 (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => {
921 Ok(crate::format::format_number(value))
922 }
923 (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => {
924 Ok(format!("{}%", crate::format::format_number(value)))
925 }
926 (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!(
927 "\"{}\"",
928 crate::output::emitter::escape_settings_string(&value)
929 )),
930 (SettingValueDomain::Enum { domain }, KeyKind::Enum(_), SettingValue::Enum(member)) => {
931 let english = table::enum_name(domain, &member).ok_or_else(|| {
932 SettingOperationError::InvalidValue {
933 setting: setting.clone(),
934 message: format!("unknown member '{member}' for enum domain '{domain}'"),
935 span: None,
936 }
937 })?;
938 localized("enums", english)
939 }
940 _ => Err(SettingOperationError::SourceUnavailable {
941 setting: setting.clone(),
942 }),
943 }
944}
945
946fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> {
947 let (name, rest) = path.split_first()?;
948 let node = children.iter().find(|node| node.name() == name)?;
949 if rest.is_empty() {
950 Some(node)
951 } else {
952 match node {
953 SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
954 find_node(children, rest)
955 }
956 _ => None,
957 }
958 }
959}
960
961fn find_node_mut<'a>(
962 children: &'a mut [SettingsNode],
963 path: &[String],
964) -> Option<&'a mut SettingsNode> {
965 let (name, rest) = path.split_first()?;
966 let node = children.iter_mut().find(|node| node.name() == name)?;
967 if rest.is_empty() {
968 Some(node)
969 } else {
970 match node {
971 SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => {
972 find_node_mut(children, rest)
973 }
974 _ => None,
975 }
976 }
977}
978
979fn value_kind(value: &SettingValue) -> &'static str {
980 match value {
981 SettingValue::Boolean(_) => "boolean",
982 SettingValue::Number(_) => "number",
983 SettingValue::Percent(_) => "percent",
984 SettingValue::String(_) => "string",
985 SettingValue::Enum(_) => "enum",
986 SettingValue::HeroList(_) => "hero-list",
987 SettingValue::MapList(_) => "map-list",
988 SettingValue::PresenceOnly => "presence-only",
989 }
990}
991
992fn domain_kind(domain: &SettingValueDomain) -> &'static str {
993 match domain {
994 SettingValueDomain::Boolean => "boolean",
995 SettingValueDomain::Number(_) => "number",
996 SettingValueDomain::Percent(_) => "percent",
997 SettingValueDomain::String => "string",
998 SettingValueDomain::Enum { .. } => "enum",
999 SettingValueDomain::HeroList => "hero-list",
1000 SettingValueDomain::MapList => "map-list",
1001 SettingValueDomain::PresenceOnly => "presence-only",
1002 }
1003}
1004
1005fn validate_value(
1006 domain: &SettingValueDomain,
1007 id: &SettingId,
1008 value: &SettingValue,
1009 span: Option<crate::core::source::Span>,
1010) -> Result<(), SettingOperationError> {
1011 let expected = domain_kind(domain);
1012 if value_kind(value) != expected {
1013 return Err(SettingOperationError::WrongValueKind {
1014 setting: id.clone(),
1015 expected,
1016 actual: value_kind(value),
1017 span,
1018 });
1019 }
1020 match (domain, value) {
1021 (
1022 SettingValueDomain::Number(_) | SettingValueDomain::Percent(_),
1023 SettingValue::Number(value) | SettingValue::Percent(value),
1024 ) if !value.is_finite() => Err(SettingOperationError::InvalidValue {
1025 setting: id.clone(),
1026 message: "numeric settings values must be finite".to_string(),
1027 span,
1028 }),
1029 (SettingValueDomain::Enum { domain }, SettingValue::Enum(member))
1030 if table::enum_name(domain, member).is_none() =>
1031 {
1032 Err(SettingOperationError::InvalidValue {
1033 setting: id.clone(),
1034 message: format!("unknown member '{member}' for enum domain '{domain}'"),
1035 span,
1036 })
1037 }
1038 (SettingValueDomain::HeroList, SettingValue::HeroList(values))
1039 if values.iter().any(|value| table::hero_name(value).is_none()) =>
1040 {
1041 Err(SettingOperationError::InvalidValue {
1042 setting: id.clone(),
1043 message: "hero list contains an unknown hero".to_string(),
1044 span,
1045 })
1046 }
1047 (SettingValueDomain::MapList, SettingValue::MapList(values))
1048 if values.iter().any(|value| table::map_name(value).is_none()) =>
1049 {
1050 Err(SettingOperationError::InvalidValue {
1051 setting: id.clone(),
1052 message: "map list contains an unknown map".to_string(),
1053 span,
1054 })
1055 }
1056 _ => Ok(()),
1057 }
1058}
1059
1060fn value_from_node(
1061 node: &SettingsNode,
1062 domain: &SettingValueDomain,
1063 id: &SettingId,
1064) -> Result<SettingValue, SettingOperationError> {
1065 let value = match node {
1066 SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value),
1067 SettingsNode::Number { value, .. } => match domain {
1068 SettingValueDomain::Percent(_) => SettingValue::Percent(*value),
1069 _ => SettingValue::Number(*value),
1070 },
1071 SettingsNode::String { value, .. } => match domain {
1072 SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()),
1073 _ => SettingValue::String(value.clone()),
1074 },
1075 SettingsNode::Flag { .. } => SettingValue::PresenceOnly,
1076 SettingsNode::List { elements, .. } => {
1077 let values = elements
1078 .iter()
1079 .map(|element| element.value.clone())
1080 .collect();
1081 match domain {
1082 SettingValueDomain::HeroList => SettingValue::HeroList(values),
1083 _ => SettingValue::MapList(values),
1084 }
1085 }
1086 _ => {
1087 return Err(SettingOperationError::InvalidValue {
1088 setting: id.clone(),
1089 message: "settings occurrence is not a typed leaf".to_string(),
1090 span: node.span(),
1091 });
1092 }
1093 };
1094 validate_value(domain, id, &value, node.span())?;
1095 Ok(value)
1096}
1097
1098fn apply_value(
1099 node: &mut SettingsNode,
1100 id: &SettingId,
1101 value: SettingValue,
1102) -> Result<(), SettingOperationError> {
1103 match (node, value) {
1104 (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => {
1105 *current = value
1106 }
1107 (
1108 SettingsNode::Number { value: current, .. },
1109 SettingValue::Number(value) | SettingValue::Percent(value),
1110 ) => *current = value,
1111 (
1112 SettingsNode::String { value: current, .. },
1113 SettingValue::String(value) | SettingValue::Enum(value),
1114 ) => *current = value,
1115 (
1116 SettingsNode::List { elements, span, .. },
1117 SettingValue::HeroList(values) | SettingValue::MapList(values),
1118 ) => {
1119 if elements.len() != values.len() {
1120 return Err(SettingOperationError::InvalidValue {
1121 setting: id.clone(),
1122 message: "source-preserving list edits cannot change list length".to_string(),
1123 span: *span,
1124 });
1125 }
1126 elements
1127 .iter_mut()
1128 .zip(values)
1129 .for_each(|(element, value)| element.value = value);
1130 }
1131 (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {}
1132 (node, value) => {
1133 return Err(SettingOperationError::WrongValueKind {
1134 setting: id.clone(),
1135 expected: "existing typed value",
1136 actual: value_kind(&value),
1137 span: node.span(),
1138 });
1139 }
1140 }
1141 Ok(())
1142}
1143
1144#[derive(Debug, Clone, PartialEq)]
1145enum TargetPattern {
1146 Global,
1147 Mode(Option<String>),
1148 Team(Option<String>),
1149 TeamAbility {
1150 team: Option<String>,
1151 slot: LogicalSlot,
1152 variant: Option<AbilityVariant>,
1153 },
1154 Hero {
1155 team: Option<String>,
1156 hero: Option<String>,
1157 },
1158 HeroAbility {
1159 team: Option<String>,
1160 hero: Option<String>,
1161 slot: LogicalSlot,
1162 variant: Option<AbilityVariant>,
1163 },
1164 Unknown,
1165}
1166
1167fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
1168 expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
1169}
1170
1171fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
1172 match target {
1173 SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
1174 _ => None,
1175 }
1176}
1177
1178fn hero_ability_exists(
1179 hero: &HeroId,
1180 slot: &LogicalSlot,
1181 variant: Option<&AbilityVariant>,
1182) -> Result<Option<bool>, GameplayDataError> {
1183 data::builtin_ref().map_err(Clone::clone).map(|catalog| {
1184 catalog.hero(hero).map(|hero| match variant {
1185 Some(variant) => hero.ability_variant(slot, variant).is_ok(),
1186 None => !hero.abilities_in_slot(slot).is_empty(),
1187 })
1188 })
1189}
1190
1191pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
1196 table::entries().map(SettingDefinition::from_entry)
1197}
1198
1199pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
1201 table::lookup(path).map(SettingDefinition::from_entry)
1202}
1203
1204pub fn definitions_by_id(id: &SettingId) -> impl Iterator<Item = SettingDefinition> {
1211 definitions().filter(move |definition| definition.id() == Some(id))
1212}
1213
1214impl SettingDefinition {
1215 fn from_entry(entry: &TableEntry) -> Self {
1216 let scope = scope_for(entry.path);
1217 let key = entry
1218 .path
1219 .last()
1220 .and_then(|part| match part {
1221 PathPart::Part(key) => Some(*key),
1222 _ => None,
1223 })
1224 .unwrap_or("");
1225 let target = target_for(entry.path);
1226 let path = table::path_string(entry.path);
1227 let domain = domain_for(entry.kind);
1228 let identity = canonical_id(scope, key, entry.path)
1229 .map(SettingIdentity::Known)
1230 .unwrap_or(SettingIdentity::Unknown);
1231 Self {
1232 identity,
1233 scope,
1234 path,
1235 path_parts: entry.path,
1236 key,
1237 target,
1238 domain,
1239 enum_domain: match entry.kind {
1240 KeyKind::BoolEnum(domain) | KeyKind::Enum(domain) => Some(domain),
1241 _ => None,
1242 },
1243 presentation: SettingPresentation {
1244 english_name: entry.workshop_name,
1245 locale_section: "labels",
1246 },
1247 source: SettingSource {
1248 kind: if table::is_generated_entry(entry) {
1249 SettingSourceKind::WorkshopDataExport
1250 } else {
1251 SettingSourceKind::RawWorkshopFixture
1252 },
1253 source: if table::is_generated_entry(entry) {
1254 "workshop-data/workshop-data.json"
1255 } else {
1256 "pinned raw Workshop settings fixtures"
1257 },
1258 reviewed: true,
1259 },
1260 }
1261 }
1262}
1263
1264fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
1265 match path.first() {
1266 Some(PathPart::Part("main")) => SettingScope::Main,
1267 Some(PathPart::Part("lobby")) => SettingScope::Lobby,
1268 Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
1269 Some(PathPart::Part("heroes")) => SettingScope::Heroes,
1270 Some(PathPart::Part("extensions")) => SettingScope::Extensions,
1271 Some(PathPart::Part("workshop")) => SettingScope::Workshop,
1272 _ => SettingScope::Unknown,
1273 }
1274}
1275
1276fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
1277 match path {
1278 [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
1279 [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
1280 TargetPattern::Mode(Some((*mode).to_string()))
1281 }
1282 [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
1283 [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
1284 target_for_hero(path, None)
1285 }
1286 [
1287 PathPart::Part("heroes"),
1288 PathPart::Part(team),
1289 PathPart::Hero,
1290 ..,
1291 ] => target_for_hero(path, Some((*team).to_string())),
1292 [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
1293 [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
1294 target_for_team(path, Some((*team).to_string()))
1295 }
1296 [
1297 PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
1298 ..,
1299 ] => TargetPattern::Global,
1300 _ => TargetPattern::Unknown,
1301 }
1302}
1303
1304fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1305 match semantic_ability_slot_for_path(path) {
1306 Some(slot) => TargetPattern::TeamAbility {
1307 team,
1308 slot: LogicalSlot::new(slot),
1309 variant: None,
1310 },
1311 None => TargetPattern::Team(team),
1312 }
1313}
1314
1315fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
1316 let slot = semantic_ability_slot_for_path(path).map(str::to_string);
1317 match slot {
1318 Some(slot) => TargetPattern::HeroAbility {
1319 team,
1320 hero: None,
1321 slot: LogicalSlot::new(slot),
1322 variant: None,
1323 },
1324 None => TargetPattern::Hero { team, hero: None },
1325 }
1326}
1327
1328fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
1329 match path.last() {
1330 Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
1331 Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
1332 Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
1333 Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
1334 Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
1335 Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
1336 Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
1337 Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
1338 _ => table::ability_slot_for_path(path),
1339 }
1340}
1341
1342fn domain_for(kind: KeyKind) -> SettingValueDomain {
1343 match kind {
1344 KeyKind::Flag => SettingValueDomain::PresenceOnly,
1345 KeyKind::String => SettingValueDomain::String,
1346 KeyKind::Bool | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean,
1347 KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
1348 KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
1349 KeyKind::Enum(domain) => SettingValueDomain::Enum {
1350 domain: domain.to_string(),
1351 },
1352 KeyKind::ListMap => SettingValueDomain::MapList,
1353 KeyKind::ListHero => SettingValueDomain::HeroList,
1354 }
1355}
1356
1357fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
1358 let prefix = match scope {
1359 SettingScope::Main => "main",
1360 SettingScope::Lobby => "lobby",
1361 SettingScope::GameModes => "gameMode",
1362 SettingScope::Heroes => "hero",
1363 SettingScope::Extensions => "extension",
1364 SettingScope::Workshop => "workshop",
1365 SettingScope::Unknown => "unknown",
1366 };
1367 if matches!(scope, SettingScope::Unknown) {
1368 return None;
1369 }
1370 let concept = canonical_concept(key, path)?;
1371 Some(SettingId::new(format!("setting.{prefix}.{concept}")))
1372}
1373
1374fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option<String> {
1378 let key = key.trim_end_matches('%');
1379 Some(match key {
1380 "health" => "health".to_string(),
1381 "damageDealt" | "damageReceived" | "healingDealt" | "healingReceived" => key.to_string(),
1382 "passiveUltGen" => "ultimateGeneration.passive".to_string(),
1383 "combatUltGen" => "ultimateGeneration.combat".to_string(),
1384 "ultGen" => "ultimateGeneration".to_string(),
1385 "enableUlt" => "ability.enabled".to_string(),
1386 "enablePrimaryFire"
1387 | "enableSecondaryFire"
1388 | "enableGenericSecondaryFire"
1389 | "enableAbility1"
1390 | "enableAbility2"
1391 | "enableAbility3" => "ability.enabled".to_string(),
1392 "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(),
1393 "enableScoping" => "primaryFire.scopingEnabled".to_string(),
1394 "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(),
1395 "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(),
1396 "setValidControlPoints" | "firstActiveControlPoint" => path
1397 .iter()
1398 .filter_map(|part| match part {
1399 PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name),
1400 _ => None,
1401 })
1402 .next()
1403 .map(|mode| format!("{key}.{mode}"))?,
1404 _ => key.to_string(),
1405 })
1406}
1407
1408pub fn validate_catalog() -> Result<(), Vec<String>> {
1411 use std::collections::{HashMap, HashSet};
1412
1413 let mut errors = Vec::new();
1414 errors.extend(reconciliation::validate());
1415 errors.extend(validate_raw_projection(table::raw_entries()));
1416 errors.extend(validate_enum_projection(
1417 table::ENUM_MEMBERS.iter(),
1418 table::GENERATED_ENUM_MEMBERS.iter(),
1419 &reconciliation::data().enum_member_mappings,
1420 ));
1421 let mut paths = HashSet::new();
1422 let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> =
1423 HashMap::new();
1424 let mut concept_keys: HashMap<(String, SettingTargetKind), String> = HashMap::new();
1425
1426 for definition in definitions() {
1427 if !paths.insert(definition.path.clone()) {
1428 errors.push(format!("duplicate settings path: {}", definition.path));
1429 }
1430 if definition.scope == SettingScope::Unknown {
1431 errors.push(format!("unknown settings scope: {}", definition.path));
1432 }
1433 let Some(id) = definition.id() else {
1434 errors.push(format!(
1435 "missing canonical settings identity: {}",
1436 definition.path
1437 ));
1438 continue;
1439 };
1440 if !definition.source.reviewed {
1441 errors.push(format!(
1442 "unreviewed settings definition: {}",
1443 definition.path
1444 ));
1445 }
1446 if definition.presentation.english_name.is_empty() {
1447 errors.push(format!(
1448 "missing settings presentation: {}",
1449 definition.path
1450 ));
1451 }
1452 let target_kind = definition.target_kind();
1453 let semantic_key = semantic_identity_key(definition.key);
1454 let collision_key = (id.as_str().to_string(), target_kind.clone());
1455 if let Some(previous_key) = concept_keys.insert(collision_key, semantic_key.clone()) {
1456 if previous_key != semantic_key {
1457 errors.push(format!(
1458 "conflicting settings concepts for {id}: {previous_key} vs {semantic_key}"
1459 ));
1460 }
1461 }
1462 let key = (id.as_str().to_string(), target_kind, semantic_key);
1463 if let Some(previous) = concepts.insert(key, definition.domain.clone()) {
1464 if previous != definition.domain {
1465 errors.push(format!("conflicting settings domains for {id}"));
1466 }
1467 }
1468 }
1469 if errors.is_empty() {
1470 Ok(())
1471 } else {
1472 Err(errors)
1473 }
1474}
1475
1476fn validate_raw_projection(
1480 entries: impl IntoIterator<Item = table::ProjectedEntry>,
1481) -> Vec<String> {
1482 use std::collections::HashMap;
1483
1484 let mut errors = Vec::new();
1485 let mut paths = HashMap::new();
1486 for projected in entries {
1487 let entry = projected.entry;
1488 if let Some(previous) = paths.insert(entry.path, projected) {
1489 if previous.entry != entry
1490 && !reconciled_entry_override(
1491 table::path_string(entry.path).as_str(),
1492 previous,
1493 projected,
1494 )
1495 {
1496 errors.push(format!(
1497 "conflicting duplicate settings path between {} and {}: {}",
1498 previous.source.label(),
1499 projected.source.label(),
1500 table::path_string(entry.path),
1501 ));
1502 }
1503 }
1504 }
1505 errors
1506}
1507
1508fn reconciled_entry_override(
1509 path: &str,
1510 fixture: table::ProjectedEntry,
1511 generated: table::ProjectedEntry,
1512) -> bool {
1513 use table::ProjectionSource::{FixtureTable, WorkshopDataExport};
1514
1515 let (fixture, generated) = match (fixture.source, generated.source) {
1516 (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry),
1517 (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry),
1518 _ => return false,
1519 };
1520 reconciliation::data()
1521 .entry_overrides
1522 .iter()
1523 .find(|override_| override_.path == path)
1524 .is_some_and(|override_| {
1525 entry_contract_matches(fixture, &override_.fixture)
1526 && entry_contract_matches(generated, &override_.generated)
1527 })
1528}
1529
1530fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool {
1531 entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected)
1532}
1533
1534fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool {
1535 match (kind, expected.kind.as_str(), expected.domain.as_deref()) {
1536 (KeyKind::Flag, "flag", None)
1537 | (KeyKind::String, "string", None)
1538 | (KeyKind::Bool, "bool", None)
1539 | (KeyKind::Number, "number", None)
1540 | (KeyKind::Percent, "percent", None)
1541 | (KeyKind::ListMap, "mapList", None)
1542 | (KeyKind::ListHero, "heroList", None) => true,
1543 (KeyKind::BoolEnum(actual), "boolEnum", Some(expected)) => actual == expected,
1544 (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected,
1545 _ => false,
1546 }
1547}
1548
1549fn validate_enum_projection(
1553 fixture_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1554 generated_entries: impl IntoIterator<Item = &'static table::EnumMember>,
1555 mappings: &[reconciliation::EnumMemberMapping],
1556) -> Vec<String> {
1557 use std::collections::{HashMap, HashSet};
1558
1559 let domains: HashSet<_> = table::entries()
1560 .filter_map(|entry| match entry.kind {
1561 KeyKind::Enum(domain) | KeyKind::BoolEnum(domain) => Some(domain),
1562 _ => None,
1563 })
1564 .collect();
1565 let mut errors = Vec::new();
1566 let mut members = HashMap::new();
1567 let mut names = HashMap::new();
1568 for member in fixture_entries {
1569 if !domains.contains(member.domain) {
1570 errors.push(format!("orphaned settings enum domain: {}", member.domain));
1571 }
1572 let key = (member.domain, member.member);
1573 if let Some(previous) = members.insert(key, member.name) {
1574 if previous != member.name {
1575 errors.push(format!(
1576 "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1577 member.domain, member.member, member.name
1578 ));
1579 }
1580 }
1581 if let Some(previous) = names.insert((member.domain, member.name), member.member) {
1582 if previous != member.member {
1583 errors.push(format!(
1584 "conflicting settings enum display name {}.{:?}: {previous} vs {}",
1585 member.domain, member.name, member.member
1586 ));
1587 }
1588 }
1589 }
1590 let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS
1591 .iter()
1592 .map(|member| ((member.domain, member.member), member))
1593 .collect();
1594 let mut mapped_sources = HashSet::new();
1595 for member in generated_entries {
1596 let key = (member.domain, member.member);
1597 if let Some(previous) = members.insert(key, member.name) {
1598 if previous != member.name {
1599 errors.push(format!(
1600 "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
1601 member.domain, member.member, member.name
1602 ));
1603 }
1604 }
1605 let mapping = mappings.iter().find(|mapping| {
1606 mapping.source_domain == member.domain && mapping.source_member == member.member
1607 });
1608 if mapping.is_none() && !domains.contains(member.domain) {
1609 errors.push(format!("orphaned settings enum domain: {}", member.domain));
1610 }
1611 let (domain, canonical_member, name) = match mapping {
1612 Some(mapping) => {
1613 if !mapped_sources.insert((
1614 mapping.source_domain.as_str(),
1615 mapping.source_member.as_str(),
1616 )) {
1617 errors.push(format!(
1618 "duplicate settings enum reconciliation for {}.{}",
1619 mapping.source_domain, mapping.source_member
1620 ));
1621 }
1622 match fixture_members.get(&(
1623 mapping.target_domain.as_str(),
1624 mapping.target_member.as_str(),
1625 )) {
1626 Some(target) if target.name == member.name => {
1627 (target.domain, target.member, target.name)
1628 }
1629 Some(target) => {
1630 errors.push(format!(
1631 "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}",
1632 mapping.source_domain, mapping.source_member,
1633 mapping.target_domain, mapping.target_member, member.name, target.name
1634 ));
1635 continue;
1636 }
1637 None => {
1638 errors.push(format!(
1639 "settings enum reconciliation target is missing: {}.{} -> {}.{}",
1640 mapping.source_domain,
1641 mapping.source_member,
1642 mapping.target_domain,
1643 mapping.target_member
1644 ));
1645 continue;
1646 }
1647 }
1648 }
1649 None => (member.domain, member.member, member.name),
1650 };
1651 if let Some(previous) = names.insert((domain, name), canonical_member) {
1652 if previous != canonical_member {
1653 errors.push(format!(
1654 "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}",
1655 domain
1656 ));
1657 }
1658 }
1659 }
1660 for mapping in mappings {
1661 if !mapped_sources.contains(&(
1662 mapping.source_domain.as_str(),
1663 mapping.source_member.as_str(),
1664 )) {
1665 errors.push(format!(
1666 "orphaned settings enum reconciliation: {}.{}",
1667 mapping.source_domain, mapping.source_member
1668 ));
1669 }
1670 }
1671 errors
1672}
1673
1674fn semantic_identity_key(key: &str) -> String {
1675 match key {
1676 "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(),
1677 _ => key.to_string(),
1678 }
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683 use super::*;
1684
1685 static DUPLICATE_PATH: [PathPart<'static>; 2] =
1686 [PathPart::Part("test"), PathPart::Part("value")];
1687 static FIXTURE_ENTRY: TableEntry = TableEntry {
1688 path: &DUPLICATE_PATH,
1689 workshop_name: "Fixture Value",
1690 kind: KeyKind::Bool,
1691 };
1692 static GENERATED_ENTRY: TableEntry = TableEntry {
1693 path: &DUPLICATE_PATH,
1694 workshop_name: "Generated Value",
1695 kind: KeyKind::Bool,
1696 };
1697 static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1698 domain: "mapRotation",
1699 member: "afterAGame",
1700 name: "After A Game",
1701 };
1702 static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1703 domain: "mapRotation",
1704 member: "afterAGame",
1705 name: "After Game",
1706 };
1707 static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember {
1708 domain: "mapRotation",
1709 member: "afterMirrorMatch",
1710 name: "After A Game",
1711 };
1712 static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember {
1713 domain: "setting_lobby_mapRotation",
1714 member: "afterGame",
1715 name: "After A Game",
1716 };
1717
1718 fn definition(target: TargetPattern) -> SettingDefinition {
1719 SettingDefinition {
1720 identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
1721 scope: SettingScope::Heroes,
1722 path: "heroes.test.value".to_string(),
1723 path_parts: &[],
1724 key: "value",
1725 target,
1726 domain: SettingValueDomain::Boolean,
1727 enum_domain: None,
1728 presentation: SettingPresentation {
1729 english_name: "Value",
1730 locale_section: "labels",
1731 },
1732 source: SettingSource {
1733 kind: SettingSourceKind::RawWorkshopFixture,
1734 source: "test",
1735 reviewed: true,
1736 },
1737 }
1738 }
1739
1740 #[test]
1741 fn common_target_narrowing_rejects_team_and_slot_mismatches() {
1742 let team = definition(TargetPattern::Team(Some("team1".to_string())));
1743 assert_eq!(
1744 team.applicability(&SettingTarget::Hero {
1745 team: Some(TeamId::new("team2")),
1746 hero: HeroId::from(crate::gameplay::hero_ids::ANA),
1747 })
1748 .expect("applicability"),
1749 Applicability::NotApplicable
1750 );
1751
1752 let team_ability = definition(TargetPattern::TeamAbility {
1753 team: Some("team1".to_string()),
1754 slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
1755 variant: None,
1756 });
1757 let target = SettingTarget::HeroAbility {
1758 team: Some(TeamId::new("team2")),
1759 hero: HeroId::from(crate::gameplay::hero_ids::DVA),
1760 slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
1761 variant: Some(AbilityVariant::new("mech")),
1762 };
1763 assert_eq!(
1764 team_ability.applicability(&target).expect("applicability"),
1765 Applicability::NotApplicable
1766 );
1767 }
1768
1769 #[test]
1770 fn raw_projection_conflicts_include_presentation_contract() {
1771 let errors = validate_raw_projection([
1772 table::ProjectedEntry {
1773 source: table::ProjectionSource::FixtureTable,
1774 entry: &FIXTURE_ENTRY,
1775 },
1776 table::ProjectedEntry {
1777 source: table::ProjectionSource::WorkshopDataExport,
1778 entry: &GENERATED_ENTRY,
1779 },
1780 ]);
1781 assert_eq!(errors.len(), 1);
1782 assert!(errors[0].contains("fixture table"));
1783 assert!(errors[0].contains("Workshop-data export"));
1784 }
1785
1786 #[test]
1787 fn enum_projection_conflicts_are_not_hidden_by_lookup_order() {
1788 let errors =
1789 validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]);
1790 assert_eq!(errors.len(), 1);
1791 assert!(errors[0].contains("mapRotation.afterAGame"));
1792 }
1793
1794 #[test]
1795 fn enum_projection_rejects_display_name_to_identity_collisions() {
1796 let errors =
1797 validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]);
1798 assert_eq!(errors.len(), 1);
1799 assert!(errors[0].contains("conflicting settings enum display name"));
1800 }
1801
1802 #[test]
1803 fn enum_projection_reconciles_export_members_to_canonical_identities() {
1804 let mappings = [reconciliation::EnumMemberMapping {
1805 source_domain: "setting_lobby_mapRotation".to_string(),
1806 source_member: "afterGame".to_string(),
1807 target_domain: "mapRotation".to_string(),
1808 target_member: "afterAGame".to_string(),
1809 }];
1810 let errors =
1811 validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings);
1812 assert!(errors.is_empty(), "{errors:?}");
1813 }
1814}