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