1use std::fmt;
8
9use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot};
10use crate::{gameplay::GameplayDataError, gameplay_data};
11
12use super::table::{self, KeyKind, PathPart, TableEntry};
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct SettingId(String);
17
18impl SettingId {
19 pub fn new(value: impl Into<String>) -> Self {
20 Self(value.into())
21 }
22
23 pub fn as_str(&self) -> &str {
24 &self.0
25 }
26}
27
28impl From<&str> for SettingId {
29 fn from(value: &str) -> Self {
30 Self::new(value)
31 }
32}
33
34impl fmt::Display for SettingId {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 formatter.write_str(self.as_str())
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum SettingIdentity {
43 Known(SettingId),
44 Unknown,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum SettingScope {
50 Main,
51 Lobby,
52 GameModes,
53 Heroes,
54 Extensions,
55 Workshop,
56 Unknown,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
61pub struct TeamId(String);
62
63impl TeamId {
64 pub fn new(value: impl Into<String>) -> Self {
65 Self(value.into())
66 }
67
68 pub fn as_str(&self) -> &str {
69 &self.0
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub enum SettingTarget {
76 Global,
77 Mode(String),
78 Team(TeamId),
79 Hero {
80 team: Option<TeamId>,
81 hero: HeroId,
82 },
83 TeamAbility {
84 team: Option<TeamId>,
85 slot: LogicalSlot,
86 variant: Option<AbilityVariant>,
87 },
88 HeroAbility {
89 team: Option<TeamId>,
90 hero: HeroId,
91 slot: LogicalSlot,
92 variant: Option<AbilityVariant>,
93 },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum SettingTargetKind {
100 Global,
101 Mode,
102 Team,
103 TeamAbility {
104 slot: LogicalSlot,
105 variant: Option<AbilityVariant>,
106 },
107 Hero,
108 HeroAbility {
109 slot: LogicalSlot,
110 variant: Option<AbilityVariant>,
111 },
112 Unknown,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Applicability {
118 Applicable,
119 NotApplicable,
120 Unknown,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum NumericBoundsError {
125 NonFinite,
126 Reversed,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
132pub struct NumericBounds {
133 min: Option<f64>,
134 max: Option<f64>,
135}
136
137impl NumericBounds {
138 pub const fn unknown() -> Self {
139 Self {
140 min: None,
141 max: None,
142 }
143 }
144
145 pub fn new(min: Option<f64>, max: Option<f64>) -> Result<Self, NumericBoundsError> {
146 if min.is_some_and(|value| !value.is_finite())
147 || max.is_some_and(|value| !value.is_finite())
148 {
149 return Err(NumericBoundsError::NonFinite);
150 }
151 if min.zip(max).is_some_and(|(min, max)| min > max) {
152 return Err(NumericBoundsError::Reversed);
153 }
154 Ok(Self { min, max })
155 }
156
157 pub fn min(&self) -> Option<f64> {
158 self.min
159 }
160
161 pub fn max(&self) -> Option<f64> {
162 self.max
163 }
164
165 pub fn effective(&self, authored: f64) -> Option<EffectiveNumber> {
166 if !authored.is_finite() || self.min.is_none() && self.max.is_none() {
167 return None;
168 }
169 match (self.min, self.max) {
170 (Some(min), None) if authored >= min => return None,
171 (None, Some(max)) if authored <= max => return None,
172 _ => {}
173 }
174 let mut effective = authored;
175 if let Some(min) = self.min {
176 effective = effective.max(min);
177 }
178 if let Some(max) = self.max {
179 effective = effective.min(max);
180 }
181 Some(EffectiveNumber {
182 authored,
183 effective,
184 })
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
190pub struct EffectiveNumber {
191 pub authored: f64,
192 pub effective: f64,
193}
194
195#[derive(Debug, Clone, PartialEq, PartialOrd)]
197pub enum SettingValueDomain {
198 Boolean,
199 Number(NumericBounds),
200 Percent(NumericBounds),
201 String,
202 Enum { domain: String },
203 HeroList,
204 MapList,
205 PresenceOnly,
206}
207
208impl SettingValueDomain {
209 pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
212 match self {
213 Self::Number(bounds) | Self::Percent(bounds) => bounds.effective(authored),
214 _ => None,
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct SettingPresentation {
222 pub english_name: &'static str,
223 pub locale_section: &'static str,
224}
225
226impl SettingPresentation {
227 pub fn localized_name(&self, locale: &str) -> Option<&'static str> {
228 if locale.eq_ignore_ascii_case("en-US") {
229 Some(self.english_name)
230 } else {
231 table::localized_name(locale, self.locale_section, self.english_name)
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct SettingProvenance {
239 pub kind: SettingEvidenceKind,
240 pub source: &'static str,
241 pub reviewed: bool,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum SettingEvidenceKind {
246 RawWorkshopFixture,
247 WorkshopDataExport,
248}
249
250#[derive(Debug, Clone, PartialEq)]
252pub struct SettingDefinition {
253 identity: SettingIdentity,
254 scope: SettingScope,
255 path: String,
256 key: &'static str,
257 target: TargetPattern,
258 domain: SettingValueDomain,
259 presentation: SettingPresentation,
260 provenance: SettingProvenance,
261}
262
263impl SettingDefinition {
264 pub fn identity(&self) -> &SettingIdentity {
265 &self.identity
266 }
267
268 pub fn id(&self) -> Option<&SettingId> {
269 match &self.identity {
270 SettingIdentity::Known(id) => Some(id),
271 SettingIdentity::Unknown => None,
272 }
273 }
274
275 pub fn scope(&self) -> SettingScope {
276 self.scope
277 }
278
279 pub fn path(&self) -> &str {
280 &self.path
281 }
282
283 pub fn domain(&self) -> &SettingValueDomain {
284 &self.domain
285 }
286
287 pub fn target_kind(&self) -> SettingTargetKind {
288 match &self.target {
289 TargetPattern::Global => SettingTargetKind::Global,
290 TargetPattern::Mode(_) => SettingTargetKind::Mode,
291 TargetPattern::Team(_) => SettingTargetKind::Team,
292 TargetPattern::TeamAbility { slot, variant, .. } => SettingTargetKind::TeamAbility {
293 slot: slot.clone(),
294 variant: variant.clone(),
295 },
296 TargetPattern::Hero { .. } => SettingTargetKind::Hero,
297 TargetPattern::HeroAbility { slot, variant, .. } => SettingTargetKind::HeroAbility {
298 slot: slot.clone(),
299 variant: variant.clone(),
300 },
301 TargetPattern::Unknown => SettingTargetKind::Unknown,
302 }
303 }
304
305 pub fn presentation(&self) -> &SettingPresentation {
306 &self.presentation
307 }
308
309 pub fn localized_name(
310 &self,
311 locale: &str,
312 target: &SettingTarget,
313 ) -> Result<Option<&'static str>, GameplayDataError> {
314 match target {
315 SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => {
316 if self.applicability(target)? == Applicability::NotApplicable {
317 Ok(None)
318 } else {
319 Ok(table::hero_setting_name(hero.as_str(), self.key, locale)
320 .or_else(|| self.presentation.localized_name(locale)))
321 }
322 }
323 _ => Ok(self.presentation.localized_name(locale)),
324 }
325 }
326
327 pub fn provenance(&self) -> SettingProvenance {
328 self.provenance
329 }
330
331 pub fn applicability(
333 &self,
334 target: &SettingTarget,
335 ) -> Result<Applicability, GameplayDataError> {
336 Ok(match (&self.target, target) {
337 (TargetPattern::Global, SettingTarget::Global) => Applicability::Applicable,
338 (TargetPattern::Mode(expected), SettingTarget::Mode(actual)) => {
339 if expected
340 .as_deref()
341 .is_none_or(|expected| expected == actual)
342 {
343 Applicability::Applicable
344 } else {
345 Applicability::NotApplicable
346 }
347 }
348 (TargetPattern::Team(expected), SettingTarget::Team(actual)) => {
349 if expected
350 .as_deref()
351 .is_none_or(|expected| expected == actual.as_str())
352 {
353 Applicability::Applicable
354 } else {
355 Applicability::NotApplicable
356 }
357 }
358 (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => {
359 if team_matches(expected.as_deref(), team.as_ref()) {
360 Applicability::Unknown
361 } else {
362 Applicability::NotApplicable
363 }
364 }
365 (
366 TargetPattern::TeamAbility {
367 team,
368 slot,
369 variant: expected_variant,
370 },
371 SettingTarget::TeamAbility {
372 team: actual_team,
373 slot: actual_slot,
374 variant: actual_variant,
375 },
376 ) => {
377 if !team_matches(team.as_deref(), actual_team.as_ref())
378 || slot != actual_slot
379 || expected_variant
380 .as_ref()
381 .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
382 {
383 Applicability::NotApplicable
384 } else {
385 Applicability::Applicable
386 }
387 }
388 (
389 TargetPattern::TeamAbility {
390 team,
391 slot,
392 variant: expected_variant,
393 },
394 SettingTarget::HeroAbility {
395 team: actual_team,
396 hero: actual_hero,
397 slot: actual_slot,
398 variant: actual_variant,
399 },
400 ) => {
401 if !team_matches(team.as_deref(), actual_team.as_ref())
402 || slot != actual_slot
403 || expected_variant
404 .as_ref()
405 .is_some_and(|expected| actual_variant.as_ref() != Some(expected))
406 {
407 Applicability::NotApplicable
408 } else {
409 match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? {
410 Some(true) => Applicability::Unknown,
411 Some(false) => Applicability::NotApplicable,
412 None => Applicability::Unknown,
413 }
414 }
415 }
416 (
417 TargetPattern::Hero { team, hero },
418 SettingTarget::Hero {
419 team: actual_team,
420 hero: actual_hero,
421 },
422 ) => {
423 if !team_matches(team.as_deref(), actual_team.as_ref())
424 || hero
425 .as_deref()
426 .is_some_and(|expected| expected != actual_hero.as_str())
427 {
428 Applicability::NotApplicable
429 } else {
430 Applicability::Unknown
431 }
432 }
433 (
434 TargetPattern::HeroAbility {
435 team,
436 hero,
437 slot,
438 variant: expected_variant,
439 },
440 SettingTarget::HeroAbility {
441 team: actual_team,
442 hero: actual_hero,
443 slot: actual_slot,
444 ..
445 },
446 ) => {
447 if !team_matches(team.as_deref(), actual_team.as_ref())
448 || hero
449 .as_deref()
450 .is_some_and(|expected| expected != actual_hero.as_str())
451 || slot.as_str() != actual_slot.as_str()
452 || expected_variant
453 .as_ref()
454 .is_some_and(|expected| Some(expected) != target_variant(target))
455 {
456 return Ok(Applicability::NotApplicable);
457 }
458 match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? {
459 None => Applicability::Unknown,
460 Some(false) => Applicability::NotApplicable,
461 Some(true) => Applicability::Unknown,
462 }
463 }
464 (TargetPattern::Unknown, _) => Applicability::Unknown,
465 _ => Applicability::NotApplicable,
466 })
467 }
468
469 pub fn effective_number(&self, authored: f64) -> Option<EffectiveNumber> {
470 self.domain.effective_number(authored)
471 }
472}
473
474#[derive(Debug, Clone, PartialEq)]
475enum TargetPattern {
476 Global,
477 Mode(Option<String>),
478 Team(Option<String>),
479 TeamAbility {
480 team: Option<String>,
481 slot: LogicalSlot,
482 variant: Option<AbilityVariant>,
483 },
484 Hero {
485 team: Option<String>,
486 hero: Option<String>,
487 },
488 HeroAbility {
489 team: Option<String>,
490 hero: Option<String>,
491 slot: LogicalSlot,
492 variant: Option<AbilityVariant>,
493 },
494 Unknown,
495}
496
497fn team_matches(expected: Option<&str>, actual: Option<&TeamId>) -> bool {
498 expected.is_none_or(|expected| actual.is_some_and(|actual| actual.as_str() == expected))
499}
500
501fn target_variant(target: &SettingTarget) -> Option<&AbilityVariant> {
502 match target {
503 SettingTarget::HeroAbility { variant, .. } => variant.as_ref(),
504 _ => None,
505 }
506}
507
508fn hero_ability_exists(
509 hero: &HeroId,
510 slot: &LogicalSlot,
511 variant: Option<&AbilityVariant>,
512) -> Result<Option<bool>, GameplayDataError> {
513 gameplay_data::builtin_ref()
514 .map_err(Clone::clone)
515 .map(|catalog| {
516 catalog.hero(hero).map(|hero| match variant {
517 Some(variant) => hero.ability_variant(slot, variant).is_ok(),
518 None => !hero.abilities_in_slot(slot).is_empty(),
519 })
520 })
521}
522
523pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
526 table::entries().map(SettingDefinition::from_entry)
527}
528
529pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
531 table::lookup(path).map(SettingDefinition::from_entry)
532}
533
534impl SettingDefinition {
535 fn from_entry(entry: &TableEntry) -> Self {
536 let scope = scope_for(entry.path);
537 let key = entry
538 .path
539 .last()
540 .and_then(|part| match part {
541 PathPart::Part(key) => Some(*key),
542 _ => None,
543 })
544 .unwrap_or("");
545 let target = target_for(entry.path);
546 let path = table::path_string(entry.path);
547 let domain = domain_for(entry.kind);
548 let identity = canonical_id(scope, key, entry.path)
549 .map(SettingIdentity::Known)
550 .unwrap_or(SettingIdentity::Unknown);
551 Self {
552 identity,
553 scope,
554 path,
555 key,
556 target,
557 domain,
558 presentation: SettingPresentation {
559 english_name: entry.workshop_name,
560 locale_section: "labels",
561 },
562 provenance: SettingProvenance {
563 kind: if table::is_generated_entry(entry) {
564 SettingEvidenceKind::WorkshopDataExport
565 } else {
566 SettingEvidenceKind::RawWorkshopFixture
567 },
568 source: if table::is_generated_entry(entry) {
569 "workshop-data/workshop-data.json"
570 } else {
571 "pinned raw Workshop settings fixtures"
572 },
573 reviewed: true,
574 },
575 }
576 }
577}
578
579fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
580 match path.first() {
581 Some(PathPart::Part("main")) => SettingScope::Main,
582 Some(PathPart::Part("lobby")) => SettingScope::Lobby,
583 Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
584 Some(PathPart::Part("heroes")) => SettingScope::Heroes,
585 Some(PathPart::Part("extensions")) => SettingScope::Extensions,
586 Some(PathPart::Part("workshop")) => SettingScope::Workshop,
587 _ => SettingScope::Unknown,
588 }
589}
590
591fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
592 match path {
593 [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Global,
594 [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
595 TargetPattern::Mode(Some((*mode).to_string()))
596 }
597 [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
598 [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
599 target_for_hero(path, None)
600 }
601 [
602 PathPart::Part("heroes"),
603 PathPart::Part(team),
604 PathPart::Hero,
605 ..,
606 ] => target_for_hero(path, Some((*team).to_string())),
607 [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
608 [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
609 target_for_team(path, Some((*team).to_string()))
610 }
611 [
612 PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
613 ..,
614 ] => TargetPattern::Global,
615 _ => TargetPattern::Unknown,
616 }
617}
618
619fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
620 match semantic_ability_slot_for_path(path) {
621 Some(slot) => TargetPattern::TeamAbility {
622 team,
623 slot: LogicalSlot::new(slot),
624 variant: None,
625 },
626 None => TargetPattern::Team(team),
627 }
628}
629
630fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
631 let slot = semantic_ability_slot_for_path(path).map(str::to_string);
632 match slot {
633 Some(slot) => TargetPattern::HeroAbility {
634 team,
635 hero: None,
636 slot: LogicalSlot::new(slot),
637 variant: None,
638 },
639 None => TargetPattern::Hero { team, hero: None },
640 }
641}
642
643fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
644 match path.last() {
645 Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
646 Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
647 Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
648 Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
649 Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
650 Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
651 Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
652 Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
653 _ => table::ability_slot_for_path(path),
654 }
655}
656
657fn domain_for(kind: KeyKind) -> SettingValueDomain {
658 match kind {
659 KeyKind::Flag => SettingValueDomain::PresenceOnly,
660 KeyKind::String => SettingValueDomain::String,
661 KeyKind::Bool => SettingValueDomain::Boolean,
662 KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
663 KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
664 KeyKind::Enum(domain) => SettingValueDomain::Enum {
665 domain: domain.to_string(),
666 },
667 KeyKind::ListMap => SettingValueDomain::MapList,
668 KeyKind::ListHero => SettingValueDomain::HeroList,
669 }
670}
671
672fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
673 let prefix = match scope {
674 SettingScope::Main => "main",
675 SettingScope::Lobby => "lobby",
676 SettingScope::GameModes => "gameMode",
677 SettingScope::Heroes => "hero",
678 SettingScope::Extensions => "extension",
679 SettingScope::Workshop => "workshop",
680 SettingScope::Unknown => "unknown",
681 };
682 if matches!(scope, SettingScope::Unknown)
683 || matches!(scope, SettingScope::Heroes) && semantic_ability_slot_for_path(path).is_some()
684 {
685 return None;
686 }
687 Some(SettingId::new(format!(
688 "setting.{prefix}.{}",
689 key.trim_end_matches('%')
690 )))
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 fn definition(target: TargetPattern) -> SettingDefinition {
698 SettingDefinition {
699 identity: SettingIdentity::Known(SettingId::new("setting.test.value")),
700 scope: SettingScope::Heroes,
701 path: "heroes.test.value".to_string(),
702 key: "value",
703 target,
704 domain: SettingValueDomain::Boolean,
705 presentation: SettingPresentation {
706 english_name: "Value",
707 locale_section: "labels",
708 },
709 provenance: SettingProvenance {
710 kind: SettingEvidenceKind::RawWorkshopFixture,
711 source: "test",
712 reviewed: true,
713 },
714 }
715 }
716
717 #[test]
718 fn common_target_narrowing_rejects_team_and_slot_mismatches() {
719 let team = definition(TargetPattern::Team(Some("team1".to_string())));
720 assert_eq!(
721 team.applicability(&SettingTarget::Hero {
722 team: Some(TeamId::new("team2")),
723 hero: HeroId::from(crate::gameplay::hero_ids::ANA),
724 })
725 .expect("applicability"),
726 Applicability::NotApplicable
727 );
728
729 let team_ability = definition(TargetPattern::TeamAbility {
730 team: Some("team1".to_string()),
731 slot: LogicalSlot::from(crate::gameplay::slots::PRIMARY_FIRE),
732 variant: None,
733 });
734 let target = SettingTarget::HeroAbility {
735 team: Some(TeamId::new("team2")),
736 hero: HeroId::from(crate::gameplay::hero_ids::DVA),
737 slot: LogicalSlot::from(crate::gameplay::slots::ABILITY_1),
738 variant: Some(AbilityVariant::new("mech")),
739 };
740 assert_eq!(
741 team_ability.applicability(&target).expect("applicability"),
742 Applicability::NotApplicable
743 );
744 }
745}