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