1use std::fmt;
2
3use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot};
4
5#[cfg(test)]
6use super::reconciliation;
7#[cfg(test)]
8use super::table::TableEntry;
9#[cfg(test)]
10use super::table::{self, KeyKind};
11use super::{PathPart, Settings, SettingsNode};
12
13mod operations;
14mod projection;
15pub use projection::{definition, definitions, definitions_by_id, validate_catalog};
16#[cfg(test)]
17use projection::{validate_enum_projection, validate_raw_projection};
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub struct SettingId(String);
22
23impl SettingId {
24 pub fn new(value: impl Into<String>) -> Self {
25 Self(value.into())
26 }
27
28 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31}
32
33impl From<&str> for SettingId {
34 fn from(value: &str) -> Self {
35 Self::new(value)
36 }
37}
38
39impl fmt::Display for SettingId {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 formatter.write_str(self.as_str())
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum SettingIdentity {
48 Known(SettingId),
49 Unknown,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum SettingScope {
56 Main,
57 Lobby,
58 GameModes,
59 Heroes,
60 Extensions,
61 Workshop,
62 Unknown,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
67pub struct TeamId(String);
68
69impl TeamId {
70 pub fn new(value: impl Into<String>) -> Self {
71 Self(value.into())
72 }
73
74 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
81pub enum SettingTarget {
82 Global,
83 Mode(String),
84 Team(TeamId),
85 Hero {
86 team: Option<TeamId>,
87 hero: HeroId,
88 },
89 TeamAbility {
90 team: Option<TeamId>,
91 slot: LogicalSlot,
92 variant: Option<AbilityVariant>,
93 },
94 HeroAbility {
95 team: Option<TeamId>,
96 hero: HeroId,
97 slot: LogicalSlot,
98 variant: Option<AbilityVariant>,
99 },
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
105pub enum SettingTargetKind {
106 Global,
107 Mode,
108 Team,
109 TeamAbility {
110 slot: LogicalSlot,
111 variant: Option<AbilityVariant>,
112 },
113 Hero,
114 HeroAbility {
115 slot: LogicalSlot,
116 variant: Option<AbilityVariant>,
117 },
118 Unknown,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Applicability {
124 Applicable,
125 NotApplicable,
126 Unknown,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum NumericBoundsError {
132 NonFinite,
133 Reversed,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
139pub struct NumericBounds {
140 min: Option<f64>,
141 max: Option<f64>,
142}
143
144impl NumericBounds {
145 pub const fn unknown() -> Self {
146 Self {
147 min: None,
148 max: None,
149 }
150 }
151
152 pub fn new(min: Option<f64>, max: Option<f64>) -> Result<Self, NumericBoundsError> {
153 if min.is_some_and(|value| !value.is_finite())
154 || max.is_some_and(|value| !value.is_finite())
155 {
156 return Err(NumericBoundsError::NonFinite);
157 }
158 if min.zip(max).is_some_and(|(min, max)| min > max) {
159 return Err(NumericBoundsError::Reversed);
160 }
161 Ok(Self { min, max })
162 }
163
164 pub fn min(&self) -> Option<f64> {
165 self.min
166 }
167
168 pub fn max(&self) -> Option<f64> {
169 self.max
170 }
171
172 pub fn effective(&self, authored: f64) -> Option<EffectiveNumber> {
173 if !authored.is_finite() || self.min.is_none() && self.max.is_none() {
174 return None;
175 }
176 match (self.min, self.max) {
177 (Some(min), None) if authored >= min => return None,
178 (None, Some(max)) if authored <= max => return None,
179 _ => {}
180 }
181 let mut effective = authored;
182 if let Some(min) = self.min {
183 effective = effective.max(min);
184 }
185 if let Some(max) = self.max {
186 effective = effective.min(max);
187 }
188 Some(EffectiveNumber {
189 authored,
190 effective,
191 })
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
197pub struct EffectiveNumber {
198 pub authored: f64,
199 pub effective: f64,
200}
201
202#[derive(Debug, Clone, PartialEq, PartialOrd)]
204#[non_exhaustive]
205pub enum SettingValueDomain {
206 Boolean,
207 Number(NumericBounds),
208 Percent(NumericBounds),
209 String,
210 Enum { domain: String },
211 HeroList,
212 MapList,
213 PresenceOnly,
214}
215
216impl SettingValueDomain {
217 pub fn kind(&self) -> &'static str {
219 match self {
220 Self::Boolean => "boolean",
221 Self::Number(_) => "number",
222 Self::Percent(_) => "percent",
223 Self::String => "string",
224 Self::Enum { .. } => "enum",
225 Self::HeroList => "hero-list",
226 Self::MapList => "map-list",
227 Self::PresenceOnly => "presence-only",
228 }
229 }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub struct SettingEnumMember {
235 domain: &'static str,
236 id: &'static str,
237 english_name: &'static str,
238}
239
240impl SettingEnumMember {
241 pub fn domain(&self) -> &str {
242 self.domain
243 }
244
245 pub fn id(&self) -> &str {
246 self.id
247 }
248
249 pub fn english_name(&self) -> &str {
250 self.english_name
251 }
252}
253
254#[derive(Debug, Clone, PartialEq)]
256pub enum SettingValue {
257 Boolean(bool),
258 Number(f64),
259 Percent(f64),
260 String(String),
261 Enum(String),
262 HeroList(Vec<String>),
263 MapList(Vec<String>),
264 PresenceOnly,
265}
266
267impl SettingValue {
268 pub fn kind(&self) -> &'static str {
271 match self {
272 Self::Boolean(_) => "boolean",
273 Self::Number(_) => "number",
274 Self::Percent(_) => "percent",
275 Self::String(_) => "string",
276 Self::Enum(_) => "enum",
277 Self::HeroList(_) => "hero-list",
278 Self::MapList(_) => "map-list",
279 Self::PresenceOnly => "presence-only",
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq)]
286pub struct SettingOccurrence {
287 pub authored: SettingValue,
288 pub effective: Option<EffectiveNumber>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct SettingSourceEdit {
297 edit: crate::core::source::SourceEdit,
298}
299
300#[derive(Debug, Clone, PartialEq)]
302#[non_exhaustive]
303pub enum SettingOperationError {
304 NotApplicable {
305 setting: SettingId,
306 target: SettingTarget,
307 },
308 NotFound {
309 setting: SettingId,
310 target: SettingTarget,
311 },
312 ApplicabilityUnknown {
313 setting: SettingId,
314 target: Box<SettingTarget>,
315 },
316 WrongValueKind {
317 setting: SettingId,
318 expected: &'static str,
319 actual: &'static str,
320 span: Option<crate::core::source::Span>,
321 },
322 InvalidValue {
323 setting: SettingId,
324 message: String,
325 span: Option<crate::core::source::Span>,
326 },
327 SourceUnavailable {
328 setting: SettingId,
329 },
330 SourceMismatch,
331}
332
333impl fmt::Display for SettingOperationError {
334 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
335 match self {
336 Self::NotApplicable { setting, target } => {
337 write!(
338 formatter,
339 "setting {setting} does not apply to target {target:?}"
340 )
341 }
342 Self::NotFound { setting, target } => {
343 write!(
344 formatter,
345 "setting {setting} was not found for target {target:?}"
346 )
347 }
348 Self::ApplicabilityUnknown { setting, target } => write!(
349 formatter,
350 "applicability of setting {setting} is unknown for target {target:?}"
351 ),
352 Self::WrongValueKind {
353 setting,
354 expected,
355 actual,
356 ..
357 } => write!(
358 formatter,
359 "setting {setting} expects {expected} value, got {actual}"
360 ),
361 Self::InvalidValue {
362 setting, message, ..
363 } => write!(formatter, "invalid value for setting {setting}: {message}"),
364 Self::SourceUnavailable { setting } => {
365 write!(formatter, "setting {setting} has no editable source")
366 }
367 Self::SourceMismatch => formatter.write_str("source no longer matches the edit"),
368 }
369 }
370}
371
372impl std::error::Error for SettingOperationError {}
373
374impl SettingValueDomain {
375 pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
378 match self {
379 Self::Number(bounds) | Self::Percent(bounds) => bounds.effective(authored),
380 _ => None,
381 }
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct SettingPresentation {
388 pub english_name: &'static str,
389 pub locale_section: &'static str,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394#[non_exhaustive]
395pub struct SettingSource {
396 pub kind: SettingSourceKind,
397 pub source: &'static str,
398 pub reviewed: bool,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub enum SettingSourceKind {
403 RawWorkshopFixture,
404 WorkshopDataExport,
405}
406
407#[derive(Debug, Clone, PartialEq)]
409pub struct SettingDefinition {
410 identity: SettingIdentity,
411 scope: SettingScope,
412 path: String,
413 path_parts: &'static [PathPart<'static>],
414 key: &'static str,
415 target: TargetPattern,
416 domain: SettingValueDomain,
417 enum_domain: Option<&'static str>,
418 presentation: SettingPresentation,
419 source: SettingSource,
420}
421
422#[derive(Debug, Clone, PartialEq)]
423enum TargetPattern {
424 Global,
425 Mode(Option<String>),
426 Team(Option<String>),
427 TeamAbility {
428 team: Option<String>,
429 slot: LogicalSlot,
430 variant: Option<AbilityVariant>,
431 },
432 Hero {
433 team: Option<String>,
434 hero: Option<String>,
435 },
436 HeroAbility {
437 team: Option<String>,
438 hero: Option<String>,
439 slot: LogicalSlot,
440 variant: Option<AbilityVariant>,
441 },
442 Unknown,
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 static DUPLICATE_PATH: [PathPart<'static>; 2] =
450 [PathPart::Part("test"), PathPart::Part("value")];
451 static FIXTURE_ENTRY: TableEntry = TableEntry {
452 path: &DUPLICATE_PATH,
453 workshop_name: "Fixture Value",
454 kind: KeyKind::Bool,
455 };
456 static GENERATED_ENTRY: TableEntry = TableEntry {
457 path: &DUPLICATE_PATH,
458 workshop_name: "Generated Value",
459 kind: KeyKind::Bool,
460 };
461 static FIXTURE_ENUM_MEMBER: table::EnumMember = table::EnumMember {
462 domain: "mapRotation",
463 member: "afterAGame",
464 name: "After A Game",
465 };
466 static GENERATED_ENUM_MEMBER: table::EnumMember = table::EnumMember {
467 domain: "mapRotation",
468 member: "afterAGame",
469 name: "After Game",
470 };
471 static DISPLAY_NAME_COLLISION: table::EnumMember = table::EnumMember {
472 domain: "mapRotation",
473 member: "afterMirrorMatch",
474 name: "After A Game",
475 };
476 static EXPORT_ENUM_MEMBER: table::EnumMember = table::EnumMember {
477 domain: "setting_lobby_mapRotation",
478 member: "afterGame",
479 name: "After A Game",
480 };
481
482 fn definition(target: TargetPattern) -> SettingDefinition {
483 SettingDefinition {
484 identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
485 scope: SettingScope::Heroes,
486 path: "heroes.test.value".to_string(),
487 path_parts: &[],
488 key: "value",
489 target,
490 domain: SettingValueDomain::Boolean,
491 enum_domain: None,
492 presentation: SettingPresentation {
493 english_name: "Value",
494 locale_section: "labels",
495 },
496 source: SettingSource {
497 kind: SettingSourceKind::RawWorkshopFixture,
498 source: "test",
499 reviewed: true,
500 },
501 }
502 }
503
504 #[test]
505 fn common_target_narrowing_rejects_team_and_slot_mismatches() {
506 let team = definition(TargetPattern::Team(Some("team1".to_string())));
507 assert_eq!(
508 team.applicability(&SettingTarget::Hero {
509 team: Some(TeamId::new("team2")),
510 hero: HeroId::from(crate::gameplay::hero_ids::ANA),
511 })
512 .expect("applicability"),
513 Applicability::NotApplicable
514 );
515
516 let team_ability = definition(TargetPattern::TeamAbility {
517 team: Some("team1".to_string()),
518 slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
519 variant: None,
520 });
521 let target = SettingTarget::HeroAbility {
522 team: Some(TeamId::new("team2")),
523 hero: HeroId::from(crate::gameplay::hero_ids::DVA),
524 slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
525 variant: Some(AbilityVariant::new("mech")),
526 };
527 assert_eq!(
528 team_ability.applicability(&target).expect("applicability"),
529 Applicability::NotApplicable
530 );
531 }
532
533 #[test]
534 fn raw_projection_conflicts_include_presentation_contract() {
535 let errors = validate_raw_projection([
536 table::ProjectedEntry {
537 source: table::ProjectionSource::FixtureTable,
538 entry: &FIXTURE_ENTRY,
539 },
540 table::ProjectedEntry {
541 source: table::ProjectionSource::WorkshopDataExport,
542 entry: &GENERATED_ENTRY,
543 },
544 ]);
545 assert_eq!(errors.len(), 1);
546 assert!(errors[0].contains("fixture table"));
547 assert!(errors[0].contains("Workshop-data export"));
548 }
549
550 #[test]
551 fn enum_projection_conflicts_are_not_hidden_by_lookup_order() {
552 let errors =
553 validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&GENERATED_ENUM_MEMBER], &[]);
554 assert_eq!(errors.len(), 1);
555 assert!(errors[0].contains("mapRotation.afterAGame"));
556 }
557
558 #[test]
559 fn enum_projection_rejects_display_name_to_identity_collisions() {
560 let errors =
561 validate_enum_projection([&FIXTURE_ENUM_MEMBER, &DISPLAY_NAME_COLLISION], [], &[]);
562 assert_eq!(errors.len(), 1);
563 assert!(errors[0].contains("conflicting settings enum display name"));
564 }
565
566 #[test]
567 fn enum_projection_reconciles_export_members_to_canonical_identities() {
568 let mappings = [reconciliation::EnumMemberMapping {
569 source_domain: "setting_lobby_mapRotation".to_string(),
570 source_member: "afterGame".to_string(),
571 target_domain: "mapRotation".to_string(),
572 target_member: "afterAGame".to_string(),
573 }];
574 let errors =
575 validate_enum_projection([&FIXTURE_ENUM_MEMBER], [&EXPORT_ENUM_MEMBER], &mappings);
576 assert!(errors.is_empty(), "{errors:?}");
577 }
578}