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