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