1use serde::Deserialize;
2use serde_json::Value;
3use std::sync::OnceLock;
4
5use super::PathPart::{self, Hero, Part, Team};
6
7const LOCALE_DATA: &str = include_str!("data/locales.json");
8
9fn locale_data() -> &'static Value {
10 static DATA: OnceLock<Value> = OnceLock::new();
11 DATA.get_or_init(|| {
12 serde_json::from_str(LOCALE_DATA).expect("generated settings locale data is valid JSON")
13 })
14}
15
16pub(crate) fn localized_name(locale: &str, section: &str, english: &str) -> Option<&'static str> {
17 let data = locale_data();
18 let aliases = data.get(section)?.get(english)?.as_object()?;
19 aliases.get(locale).and_then(Value::as_str).or_else(|| {
20 aliases.iter().find_map(|(known, value)| {
21 known
22 .eq_ignore_ascii_case(locale)
23 .then(|| value.as_str())
24 .flatten()
25 })
26 })
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct LocalizedSettingName {
32 pub section: &'static str,
35 pub english: &'static str,
37 pub locale: &'static str,
38 pub spelling: &'static str,
39}
40
41const LOCALE_SECTIONS: &[&str] = &[
42 "enums",
43 "heroes",
44 "labels",
45 "maps",
46 "modes",
47 "namespaces",
48 "teams",
49 "tokens",
50];
51
52pub fn localized_setting_names() -> &'static [LocalizedSettingName] {
55 static NAMES: OnceLock<Vec<LocalizedSettingName>> = OnceLock::new();
56 NAMES.get_or_init(|| {
57 let data = locale_data();
58 let mut names = Vec::new();
59 for section in LOCALE_SECTIONS {
60 let Some(rows) = data.get(section).and_then(Value::as_object) else {
61 continue;
62 };
63 for (english, row) in rows {
64 let Some(aliases) = row.as_object() else {
65 continue;
66 };
67 for (locale, value) in aliases {
68 let Some(spelling) = value.as_str() else {
69 continue;
70 };
71 names.push(LocalizedSettingName {
72 section,
73 english,
74 locale,
75 spelling,
76 });
77 }
78 }
79 }
80 names
81 })
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub(crate) enum KeyKind {
86 Flag,
87 String,
88 Bool,
89 YesNo,
90 BoolEnum(&'static str),
91 Number,
92 Percent,
93 Enum(&'static str),
94 ListMap,
95 ListHero,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) struct TableEntry {
100 pub(crate) path: &'static [PathPart<'static>],
101 pub(crate) workshop_name: &'static str,
102 pub(crate) kind: KeyKind,
103}
104
105macro_rules! entry {
106 ($path:expr, $name:expr, $kind:expr) => {
107 TableEntry {
108 path: &$path,
109 workshop_name: $name,
110 kind: $kind,
111 }
112 };
113}
114
115pub(crate) static ENTRIES: &[TableEntry] = &[
116 entry!(
117 [Part("main"), Part("description")],
118 "Description",
119 KeyKind::String
120 ),
121 entry!(
122 [Part("main"), Part("modeName")],
123 "Mode Name",
124 KeyKind::String
125 ),
126 entry!(
127 [Part("lobby"), Part("ffaSlots")],
128 "Max FFA Players",
129 KeyKind::Number
130 ),
131 entry!(
132 [Part("lobby"), Part("mapRotation")],
133 "Map Rotation",
134 KeyKind::Enum("mapRotation")
135 ),
136 entry!(
137 [Part("lobby"), Part("spectatorSlots")],
138 "Max Spectators",
139 KeyKind::Number
140 ),
141 entry!(
142 [Part("lobby"), Part("enableMatchVoiceChat")],
143 "Match Voice Chat",
144 KeyKind::BoolEnum("matchVoiceChat")
145 ),
146 entry!(
147 [Part("lobby"), Part("team1Slots")],
148 "Max Team 1 Players",
149 KeyKind::Number
150 ),
151 entry!(
152 [Part("lobby"), Part("team2Slots")],
153 "Max Team 2 Players",
154 KeyKind::Number
155 ),
156 entry!(
157 [Part("lobby"), Part("returnToLobby")],
158 "Return To Lobby",
159 KeyKind::Enum("returnToLobby")
160 ),
161 entry!(
162 [Part("lobby"), Part("allowPlayersInQueue")],
163 "Allow Players Who Are In Queue",
164 KeyKind::YesNo
165 ),
166 entry!(
167 [Part("lobby"), Part("swapTeamsAfterMatch")],
168 "Swap Teams After Match",
169 KeyKind::YesNo
170 ),
171 entry!(
172 [Part("gamemodes"), Part("assault"), Part("enabled")],
173 "enabled",
174 KeyKind::Bool
175 ),
176 entry!(
177 [Part("gamemodes"), Part("control"), Part("enabled")],
178 "enabled",
179 KeyKind::Bool
180 ),
181 entry!(
182 [Part("gamemodes"), Part("escort"), Part("enabled")],
183 "enabled",
184 KeyKind::Bool
185 ),
186 entry!(
187 [Part("gamemodes"), Part("hybrid"), Part("enabled")],
188 "enabled",
189 KeyKind::Bool
190 ),
191 entry!(
192 [Part("gamemodes"), Part("assault"), Part("enabledMaps")],
193 "enabled maps",
194 KeyKind::ListMap
195 ),
196 entry!(
197 [Part("gamemodes"), Part("control"), Part("enabledMaps")],
198 "enabled maps",
199 KeyKind::ListMap
200 ),
201 entry!(
202 [Part("gamemodes"), Part("escort"), Part("enabledMaps")],
203 "enabled maps",
204 KeyKind::ListMap
205 ),
206 entry!(
207 [Part("gamemodes"), Part("hybrid"), Part("enabledMaps")],
208 "enabled maps",
209 KeyKind::ListMap
210 ),
211 entry!(
212 [Part("gamemodes"), Part("skirmish"), Part("enabledMaps")],
213 "enabled maps",
214 KeyKind::ListMap
215 ),
216 entry!(
217 [Part("gamemodes"), Part("assault"), Part("disabledMaps")],
218 "disabled maps",
219 KeyKind::ListMap
220 ),
221 entry!(
222 [Part("gamemodes"), Part("skirmish"), Part("disabledMaps")],
223 "disabled maps",
224 KeyKind::ListMap
225 ),
226 entry!(
227 [Part("gamemodes"), Part("ffa"), Part("enabledMaps")],
228 "enabled maps",
229 KeyKind::ListMap
230 ),
231 entry!(
232 [Part("gamemodes"), Part("tdm"), Part("enabledMaps")],
233 "enabled maps",
234 KeyKind::ListMap
235 ),
236 entry!(
237 [Part("gamemodes"), Part("assault"), Part("roleLimit")],
238 "Limit Roles",
239 KeyKind::Enum("roleLimit")
240 ),
241 entry!(
242 [Part("gamemodes"), Part("control"), Part("roleLimit")],
243 "Limit Roles",
244 KeyKind::Enum("roleLimit")
245 ),
246 entry!(
247 [Part("gamemodes"), Part("escort"), Part("roleLimit")],
248 "Limit Roles",
249 KeyKind::Enum("roleLimit")
250 ),
251 entry!(
252 [Part("gamemodes"), Part("hybrid"), Part("roleLimit")],
253 "Limit Roles",
254 KeyKind::Enum("roleLimit")
255 ),
256 entry!(
257 [Part("gamemodes"), Part("general"), Part("roleLimit")],
258 "Limit Roles",
259 KeyKind::Enum("roleLimit")
260 ),
261 entry!(
262 [
263 Part("gamemodes"),
264 Part("assault"),
265 Part("enableCompetitiveRules")
266 ],
267 "Competitive Rules",
268 KeyKind::Bool
269 ),
270 entry!(
271 [
272 Part("gamemodes"),
273 Part("control"),
274 Part("enableCompetitiveRules")
275 ],
276 "Competitive Rules",
277 KeyKind::Bool
278 ),
279 entry!(
280 [
281 Part("gamemodes"),
282 Part("escort"),
283 Part("enableCompetitiveRules")
284 ],
285 "Competitive Rules",
286 KeyKind::Bool
287 ),
288 entry!(
289 [
290 Part("gamemodes"),
291 Part("hybrid"),
292 Part("enableCompetitiveRules")
293 ],
294 "Competitive Rules",
295 KeyKind::Bool
296 ),
297 entry!(
298 [
299 Part("gamemodes"),
300 Part("general"),
301 Part("enableCompetitiveRules")
302 ],
303 "Competitive Rules",
304 KeyKind::Bool
305 ),
306 entry!(
307 [Part("gamemodes"), Part("general"), Part("enablePerks")],
308 "Enable Perks",
309 KeyKind::Bool
310 ),
311 entry!(
312 [Part("gamemodes"), Part("general"), Part("heroLimit")],
313 "Hero Limit",
314 KeyKind::Enum("heroLimit")
315 ),
316 entry!(
317 [Part("gamemodes"), Part("general"), Part("respawnTime%")],
318 "Respawn Time Scalar",
319 KeyKind::Percent
320 ),
321 entry!(
322 [
323 Part("gamemodes"),
324 Part("general"),
325 Part("enableHeroSwitching")
326 ],
327 "Allow Hero Switching",
328 KeyKind::Bool
329 ),
330 entry!(
331 [
332 Part("gamemodes"),
333 Part("general"),
334 Part("enableRandomHeroes")
335 ],
336 "Respawn As Random Hero",
337 KeyKind::Bool
338 ),
339 entry!(
340 [
341 Part("gamemodes"),
342 Part("general"),
343 Part("gamemodeStartTrigger")
344 ],
345 "Game Mode Start",
346 KeyKind::Enum("gamemodeStartTrigger")
347 ),
348 entry!(
349 [
350 Part("gamemodes"),
351 Part("assault"),
352 Part("gamemodeStartTrigger")
353 ],
354 "Game Mode Start",
355 KeyKind::Enum("gamemodeStartTrigger")
356 ),
357 entry!(
358 [
359 Part("gamemodes"),
360 Part("assault"),
361 Part("tankPassiveHealthBonus")
362 ],
363 "Tank Role Passive Health Bonus",
364 KeyKind::Enum("tankPassiveHealthBonus")
365 ),
366 entry!(
367 [Part("gamemodes"), Part("general"), Part("spawnHealthPacks")],
368 "Spawn Health Packs",
369 KeyKind::Enum("spawnHealthPacks")
370 ),
371 entry!(
372 [Part("heroes"), Team, Part("enabledHeroes")],
373 "enabled heroes",
374 KeyKind::ListHero
375 ),
376 entry!(
377 [Part("heroes"), Team, Part("disabledHeroes")],
378 "disabled heroes",
379 KeyKind::ListHero
380 ),
381 entry!(
382 [Part("heroes"), Part("general"), Part("disabledHeroes")],
383 "disabled heroes",
384 KeyKind::ListHero
385 ),
386 entry!(
387 [Part("heroes"), Team, Hero, Part("enablePrimaryFire")],
388 "Primary Fire",
389 KeyKind::Bool
390 ),
391 entry!(
392 [Part("heroes"), Team, Hero, Part("enableSecondaryFire")],
393 "Secondary Fire",
394 KeyKind::Bool
395 ),
396 entry!(
397 [Part("heroes"), Team, Hero, Part("enableAbility1")],
398 "Ability 1",
399 KeyKind::Bool
400 ),
401 entry!(
402 [Part("heroes"), Team, Hero, Part("enableAbility2")],
403 "Ability 2",
404 KeyKind::Bool
405 ),
406 entry!(
407 [Part("heroes"), Team, Hero, Part("health%")],
408 "Health",
409 KeyKind::Percent
410 ),
411 entry!(
412 [Part("heroes"), Team, Hero, Part("passiveUltGen%")],
413 "Ultimate Generation - Passive Blizzard",
414 KeyKind::Percent
415 ),
416 entry!(
417 [Part("heroes"), Team, Hero, Part("combatUltGen%")],
418 "Ultimate Generation - Combat Blizzard",
419 KeyKind::Percent
420 ),
421];
422
423#[derive(Debug, Clone, Copy)]
424pub(crate) struct NameMap {
425 pub(crate) key: &'static str,
426 pub(crate) name: &'static str,
427}
428
429const fn named(key: &'static str, name: &'static str) -> NameMap {
430 NameMap { key, name }
431}
432
433pub(crate) static MODE_NAMES: &[NameMap] = &[
434 named("assault", "Assault"),
435 named("control", "Control"),
436 named("escort", "Escort"),
437 named("hybrid", "Hybrid"),
438 named("skirmish", "Skirmish"),
439 named("ffa", "Deathmatch"),
440 named("tdm", "Team Deathmatch"),
441 named("general", "General"),
442];
443
444pub(crate) static MAP_NAMES: &[NameMap] = &[
445 named("workshopIsland", "Workshop Island"),
446 named("kingsRowWinter", "King's Row Winter"),
447];
448
449pub(crate) static HERO_NAMES: &[NameMap] = &[
450 named("anran", "Anran"),
451 named("ana", "Ana"),
452 named("ashe", "Ashe"),
453 named("bastion", "Bastion"),
454 named("baptiste", "Baptiste"),
455 named("brigitte", "Brigitte"),
456 named("cassidy", "Cassidy"),
457 named("dmon", "D.Mon"),
458 named("domina", "Domina"),
459 named("dva", "D.Va"),
460 named("doomfist", "Doomfist"),
461 named("echo", "Echo"),
462 named("emre", "Emre"),
463 named("freja", "Freja"),
464 named("genji", "Genji"),
465 named("hanzo", "Hanzo"),
466 named("moira", "Moira"),
467 named("reinhardt", "Reinhardt"),
468 named("hazard", "Hazard"),
469 named("illari", "Illari"),
470 named("juno", "Juno"),
471 named("jetpackCat", "Jetpack Cat"),
472 named("junkerQueen", "Junker Queen"),
473 named("junkrat", "Junkrat"),
474 named("kiriko", "Kiriko"),
475 named("lucio", "Lúcio"),
476 named("mauga", "Mauga"),
477 named("mercy", "Mercy"),
478 named("mizuki", "Mizuki"),
479 named("orisa", "Orisa"),
480 named("pharah", "Pharah"),
481 named("reaper", "Reaper"),
482 named("roadhog", "Roadhog"),
483 named("shion", "Shion"),
484 named("sierra", "Sierra"),
485 named("sigma", "Sigma"),
486 named("ramattra", "Ramattra"),
487 named("lifeweaver", "Lifeweaver"),
488 named("sojourn", "Sojourn"),
489 named("soldier", "Soldier: 76"),
490 named("sombra", "Sombra"),
491 named("symmetra", "Symmetra"),
492 named("torbjorn", "Torbjörn"),
493 named("tracer", "Tracer"),
494 named("venture", "Venture"),
495 named("widowmaker", "Widowmaker"),
496 named("winston", "Winston"),
497 named("wuyang", "Wuyang"),
498 named("wreckingBall", "Wrecking Ball"),
499 named("zarya", "Zarya"),
500 named("zenyatta", "Zenyatta"),
501 named("mei", "Mei"),
502];
503
504include!("data/generated_map_entries.rs");
505include!("data/generated_hero_entries.rs");
506include!("data/generated_mode_entries.rs");
507
508pub(crate) static TEAM_NAMES: &[NameMap] = &[
509 named("allTeams", "General"),
510 named("team1", "Team 1"),
511 named("team2", "Team 2"),
512];
513
514#[derive(Debug, Clone, Copy)]
515pub(crate) struct EnumMember {
516 pub(crate) domain: &'static str,
517 pub(crate) member: &'static str,
518 pub(crate) name: &'static str,
519}
520
521const fn enum_member(domain: &'static str, member: &'static str, name: &'static str) -> EnumMember {
522 EnumMember {
523 domain,
524 member,
525 name,
526 }
527}
528
529include!("data/generated_entries.rs");
530include!("data/generated_hero_settings.rs");
531
532pub(crate) static ENUM_MEMBERS: &[EnumMember] = &[
533 enum_member("mapRotation", "afterAGame", "After A Game"),
534 enum_member("mapRotation", "afterMirrorMatch", "After A Mirror Match"),
535 enum_member("mapRotation", "paused", "Paused"),
536 enum_member("matchVoiceChat", "enabled", "Enabled"),
537 enum_member("returnToLobby", "never", "Never"),
538 enum_member("returnToLobby", "afterAGame", "After A Game"),
539 enum_member("returnToLobby", "afterMirrorMatch", "After A Mirror Match"),
540 enum_member("gamemodeStartTrigger", "immediately", "Immediately"),
541 enum_member("gamemodeStartTrigger", "manual", "Manual"),
542 enum_member("gamemodeStartTrigger", "allSlotsFilled", "All Slots Filled"),
543 enum_member("spawnHealthPacks", "disabled", "Disabled"),
544 enum_member("spawnHealthPacks", "modeDependent", "Determined By Mode"),
545 enum_member("spawnHealthPacks", "enabled", "Enabled"),
546 enum_member("roleLimit", "2OfEachRolePerTeam", "2 Of Each Role Per Team"),
547 enum_member(
548 "roleLimit",
549 "1Tank2Offense2Support",
550 "1 Tank 2 Offense 2 Support",
551 ),
552 enum_member("roleLimit", "off", "Off"),
553 enum_member("tankPassiveHealthBonus", "alwaysEnabled", "Always Enabled"),
554 enum_member("tankPassiveHealthBonus", "disabled", "Disabled"),
555 enum_member(
556 "tankPassiveHealthBonus",
557 "1Tank2Offense2Support",
558 "1 Tank 2 Offense 2 Support",
559 ),
560 enum_member("heroLimit", "off", "Off"),
561 enum_member("heroLimit", "1PerTeam", "1 Per Team"),
562 enum_member("heroLimit", "2PerTeam", "2 Per Team"),
563 enum_member("heroLimit", "1PerGame", "1 Per Game"),
564 enum_member("heroLimit", "2PerGame", "2 Per Game"),
565];
566
567pub(crate) fn lookup(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
568 exact_entry(path).or_else(|| mode_inherited_entry(path))
569}
570
571fn exact_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
572 entry_bucket(path).find(|entry| entry.path == path)
573}
574
575fn entry_bucket(path: &[PathPart<'_>]) -> impl Iterator<Item = &'static TableEntry> {
576 static INDEX: OnceLock<std::collections::HashMap<u64, Vec<&'static TableEntry>>> =
577 OnceLock::new();
578 let index = INDEX.get_or_init(|| {
579 let mut index: std::collections::HashMap<u64, Vec<&'static TableEntry>> =
580 std::collections::HashMap::new();
581 for entry in entries() {
582 index.entry(path_hash(entry.path)).or_default().push(entry);
583 }
584 index
585 });
586 index
587 .get(&path_hash(path))
588 .into_iter()
589 .flat_map(|bucket| bucket.iter().copied())
590}
591
592fn path_hash(path: &[PathPart<'_>]) -> u64 {
593 use std::hash::{Hash, Hasher};
594 let mut hasher = std::collections::hash_map::DefaultHasher::new();
595 path.hash(&mut hasher);
596 hasher.finish()
597}
598
599fn declared_modes() -> &'static std::collections::HashSet<&'static str> {
600 static MODES: OnceLock<std::collections::HashSet<&'static str>> = OnceLock::new();
601 MODES.get_or_init(|| {
602 entries()
603 .filter_map(|entry| {
604 let [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] = entry.path else {
605 return None;
606 };
607 Some(*mode)
608 })
609 .collect()
610 })
611}
612
613pub(crate) fn child_entries(parent: &[PathPart<'_>]) -> Vec<&'static TableEntry> {
614 static CHILDREN: OnceLock<std::collections::HashMap<u64, Vec<(usize, &'static TableEntry)>>> =
615 OnceLock::new();
616 let children = CHILDREN.get_or_init(|| {
617 let mut children: std::collections::HashMap<u64, Vec<(usize, &'static TableEntry)>> =
618 std::collections::HashMap::new();
619 for entry in entries() {
620 if let Some((_, parent)) = entry.path.split_last() {
621 children
622 .entry(path_hash(parent))
623 .or_default()
624 .push((entry.path.len() - 1, entry));
625 }
626 }
627 children
628 });
629 children
630 .get(&path_hash(parent))
631 .map(|bucket| {
632 bucket
633 .iter()
634 .filter(|(parent_len, entry)| {
635 *parent_len == parent.len() && entry.path[..parent.len()] == *parent
636 })
637 .map(|(_, entry)| *entry)
638 .collect()
639 })
640 .unwrap_or_default()
641}
642
643static ELIMINATION_GENERAL_KEYS: &[&str] = &[
644 "disabledMaps",
645 "enableEnemyHealthBars",
646 "enableKillCam",
647 "enableKillFeed",
648 "enableSkins",
649 "enabledMaps",
650 "gamemodeStartTrigger",
651 "healthPackRespawnTime%",
652 "perkEliminationCatchupLevelAmount%",
653 "perkGeneration%",
654 "spawnHealthPacks",
655 "teamOverlay",
656];
657
658pub(crate) fn mode_inherited_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
659 let [
660 PathPart::Part("gamemodes"),
661 PathPart::Part(mode),
662 PathPart::Part(key),
663 ] = path
664 else {
665 return None;
666 };
667 if *mode == "general" {
668 return None;
669 }
670 let inherits = if *mode == "elimination" {
671 ELIMINATION_GENERAL_KEYS.contains(key)
672 } else {
673 declared_modes().contains(mode)
674 };
675 if !inherits {
676 return None;
677 }
678 exact_entry(&[
679 PathPart::Part("gamemodes"),
680 PathPart::Part("general"),
681 PathPart::Part(key),
682 ])
683}
684
685pub(crate) fn entries() -> impl Iterator<Item = &'static TableEntry> {
686 deduplicated_entries(ENTRIES.iter().chain(GENERATED_ENTRIES.iter()))
687}
688
689pub(crate) fn raw_entries() -> impl Iterator<Item = ProjectedEntry> {
690 ENTRIES
691 .iter()
692 .map(|entry| ProjectedEntry {
693 source: ProjectionSource::FixtureTable,
694 entry,
695 })
696 .chain(GENERATED_ENTRIES.iter().map(|entry| ProjectedEntry {
697 source: ProjectionSource::WorkshopDataExport,
698 entry,
699 }))
700}
701
702#[derive(Debug, Clone, Copy)]
703pub(crate) struct ProjectedEntry {
704 pub(crate) source: ProjectionSource,
705 pub(crate) entry: &'static TableEntry,
706}
707
708#[derive(Debug, Clone, Copy)]
709pub(crate) enum ProjectionSource {
710 FixtureTable,
711 WorkshopDataExport,
712}
713
714impl ProjectionSource {
715 pub(crate) const fn label(self) -> &'static str {
716 match self {
717 Self::FixtureTable => "fixture table",
718 Self::WorkshopDataExport => "Workshop-data export",
719 }
720 }
721}
722
723fn deduplicated_entries(
724 entries: impl Iterator<Item = &'static TableEntry>,
725) -> impl Iterator<Item = &'static TableEntry> {
726 let mut paths = std::collections::HashSet::new();
727 entries.filter(move |entry| paths.insert(path_string(entry.path)))
728}
729
730pub(crate) fn is_generated_entry(entry: &TableEntry) -> bool {
731 GENERATED_ENTRIES
732 .iter()
733 .any(|candidate| std::ptr::eq(candidate, entry))
734}
735
736pub(crate) fn ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
737 match path.last() {
738 Some(PathPart::Part("ability1Cooldown%" | "enableAbility1")) => Some("ability1"),
739 Some(PathPart::Part("ability2Cooldown%" | "enableAbility2")) => Some("ability2"),
740 Some(PathPart::Part("ability3Cooldown%" | "enableAbility3")) => Some("ability3"),
741 Some(PathPart::Part(
742 "secondaryFireCooldown%"
743 | "secondaryFireEnergyChargeRate%"
744 | "secondaryFireMaximumTime%"
745 | "secondaryFireRechargeRate%"
746 | "enableSecondaryFire"
747 | "enableGenericSecondaryFire",
748 )) => Some("secondaryFire"),
749 Some(PathPart::Part("combatUltGen%" | "passiveUltGen%" | "ultGen%" | "enableUlt")) => {
750 Some("ultimate")
751 }
752 Some(PathPart::Part("enablePassive")) => Some("passive"),
753 _ => None,
754 }
755}
756
757pub(crate) fn hero_setting_name(hero: &str, key: &str, locale: &str) -> Option<&'static str> {
758 hero_setting_aliases()
759 .iter()
760 .find(|alias| {
761 alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
762 })
763 .map(|alias| alias.display.as_str())
764 .or_else(|| {
765 GENERATED_HERO_SETTING_NAMES
766 .iter()
767 .find(|entry| entry.hero == hero && entry.key == key)
768 .and_then(|entry| entry.localized(locale))
769 })
770 .or_else(|| {
771 if locale.eq_ignore_ascii_case("en-US") {
772 GENERATED_HERO_SETTING_NAMES
773 .iter()
774 .find(|entry| entry.hero == hero && entry.key == key)
775 .filter(|entry| {
776 entry.locales.iter().any(|(known, value)| {
777 known.eq_ignore_ascii_case(locale)
778 && (value.trim().is_empty() || value.starts_with(' '))
779 })
780 })
781 .map(|entry| entry.key)
782 } else {
783 None
784 }
785 })
786}
787
788pub(crate) fn hero_setting_display_matches(
789 hero: &str,
790 key: &str,
791 locale: &str,
792 display: &str,
793) -> bool {
794 if hero_setting_has_alias(hero, key, locale) {
795 return false;
796 }
797 GENERATED_HERO_SETTING_NAMES
798 .iter()
799 .filter(|entry| entry.hero == hero && entry.key == key)
800 .filter_map(|entry| entry.localized(locale))
801 .any(|name| names_eq(name, display))
802}
803
804pub(crate) fn hero_key_template(key: &str) -> Option<&'static str> {
805 GENERATED_ENTRIES
806 .iter()
807 .find(|entry| {
808 matches!(
809 entry.path,
810 [
811 PathPart::Part("heroes"),
812 PathPart::Team,
813 PathPart::Hero,
814 PathPart::Part(candidate)
815 ] if *candidate == key
816 )
817 })
818 .map(|entry| entry.workshop_name)
819 .filter(|name| name.contains("%1$s"))
820}
821
822#[derive(Deserialize)]
823struct HeroSettingAlias {
824 hero: String,
825 key: String,
826 locale: String,
827 display: String,
828}
829
830fn hero_setting_aliases() -> &'static [HeroSettingAlias] {
831 static ALIASES: OnceLock<Vec<HeroSettingAlias>> = OnceLock::new();
832 ALIASES.get_or_init(|| {
833 serde_json::from_str(include_str!("data/hero_setting_aliases.json"))
834 .expect("hero setting alias data is valid JSON")
835 })
836}
837
838pub(crate) fn hero_setting_has_alias(hero: &str, key: &str, locale: &str) -> bool {
839 hero_setting_aliases().iter().any(|alias| {
840 alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
841 })
842}
843
844pub(crate) fn hero_setting_alias(hero: &str, key: &str, locale: &str, display: &str) -> bool {
845 hero_setting_aliases().iter().any(|alias| {
846 alias.hero == hero
847 && alias.key == key
848 && alias.locale.eq_ignore_ascii_case(locale)
849 && names_eq(&alias.display, display)
850 })
851}
852
853const BOOL_VALUE_ALIASES: &[(&str, &str, &str, bool)] =
854 &[("enableSecondaryFire", "zh-CN", "禁用", false)];
855
856pub(crate) fn bool_value_alias(key: &str, locale: &str, display: &str) -> Option<bool> {
857 BOOL_VALUE_ALIASES
858 .iter()
859 .find(|(alias_key, alias_locale, spelling, _)| {
860 *alias_key == key
861 && alias_locale.eq_ignore_ascii_case(locale)
862 && names_eq(spelling, display)
863 })
864 .map(|(_, _, _, value)| *value)
865}
866
867fn name_in(maps: &[NameMap], key: &str) -> Option<&'static str> {
868 maps.iter().find(|m| m.key == key).map(|m| m.name)
869}
870
871pub(crate) fn names_eq(left: &str, right: &str) -> bool {
872 left == right
873 || left.to_lowercase() == right.to_lowercase()
874 || crate::catalog::normalize_spelling(left).to_lowercase()
875 == crate::catalog::normalize_spelling(right).to_lowercase()
876}
877
878pub(crate) fn map_spellings() -> impl Iterator<Item = &'static str> {
879 MAP_NAMES
880 .iter()
881 .chain(GENERATED_MAP_NAMES.iter())
882 .map(|m| m.name)
883}
884
885pub(crate) fn hero_spellings() -> impl Iterator<Item = &'static str> {
886 HERO_NAMES
887 .iter()
888 .chain(GENERATED_HERO_NAMES.iter())
889 .map(|m| m.name)
890}
891
892pub(crate) fn team_spellings() -> impl Iterator<Item = &'static str> {
893 TEAM_NAMES.iter().map(|m| m.name)
894}
895
896pub(crate) fn enum_spellings(domain: &str) -> impl Iterator<Item = &'static str> {
897 enum_members(domain).map(|member| member.name)
898}
899
900pub(crate) fn key_spellings(parent: &[PathPart<'_>]) -> Vec<&'static str> {
901 let mut spellings: Vec<&'static str> = child_entries(parent)
902 .iter()
903 .filter(|entry| !entry.workshop_name.contains("%1$s"))
904 .map(|entry| entry.workshop_name)
905 .collect();
906 let [PathPart::Part("gamemodes"), PathPart::Part(mode)] = parent else {
907 return spellings;
908 };
909 if *mode == "general" {
910 return spellings;
911 }
912 if *mode == "elimination" {
913 spellings.extend(
914 child_entries(&[PathPart::Part("gamemodes"), PathPart::Part("general")])
915 .iter()
916 .filter_map(|entry| {
917 let PathPart::Part(key) = entry.path.last()? else {
918 return None;
919 };
920 ELIMINATION_GENERAL_KEYS
921 .contains(key)
922 .then_some(entry.workshop_name)
923 }),
924 );
925 } else if declared_modes().contains(mode) {
926 spellings.extend(
927 child_entries(&[PathPart::Part("gamemodes"), PathPart::Part("general")])
928 .iter()
929 .filter_map(|entry| {
930 (!entry.workshop_name.contains("%1$s")).then_some(entry.workshop_name)
931 }),
932 );
933 }
934 spellings
935}
936
937pub(crate) fn mode_name(key: &str) -> Option<&'static str> {
938 name_in(MODE_NAMES, key).or_else(|| name_in(GENERATED_MODE_NAMES, key))
939}
940
941pub(crate) fn map_name(key: &str) -> Option<&'static str> {
942 name_in(MAP_NAMES, key).or_else(|| name_in(GENERATED_MAP_NAMES, key))
943}
944
945pub(crate) fn hero_name(key: &str) -> Option<&'static str> {
946 name_in(HERO_NAMES, key).or_else(|| name_in(GENERATED_HERO_NAMES, key))
947}
948
949pub(crate) fn team_name(key: &str) -> Option<&'static str> {
950 name_in(TEAM_NAMES, key)
951}
952
953pub(crate) fn enum_name(domain: &str, member: &str) -> Option<&'static str> {
954 ENUM_MEMBERS
955 .iter()
956 .find(|m| m.domain == domain && m.member == member)
957 .map(|m| m.name)
958 .or_else(|| {
959 GENERATED_ENUM_MEMBERS
960 .iter()
961 .find(|m| m.domain == domain && m.member == member)
962 .map(|m| m.name)
963 })
964}
965
966pub(crate) fn enum_members(domain: &str) -> impl Iterator<Item = &'static EnumMember> {
967 ENUM_MEMBERS
968 .iter()
969 .chain(GENERATED_ENUM_MEMBERS.iter())
970 .filter(move |member| member.domain == domain)
971}
972
973pub(crate) fn path_string(path: &[PathPart<'_>]) -> String {
974 path.iter()
975 .map(|part| match part {
976 PathPart::Part(name) => (*name).to_string(),
977 PathPart::Team => "<team>".to_string(),
978 PathPart::Hero => "<hero>".to_string(),
979 })
980 .collect::<Vec<_>>()
981 .join(".")
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987
988 fn scan_exact(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
989 entries().find(|entry| entry.path == path)
990 }
991
992 fn scan_mode_inherited(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
993 let [
994 PathPart::Part("gamemodes"),
995 PathPart::Part(mode),
996 PathPart::Part(key),
997 ] = path
998 else {
999 return None;
1000 };
1001 if *mode == "general" {
1002 return None;
1003 }
1004 let inherits = if *mode == "elimination" {
1005 ELIMINATION_GENERAL_KEYS.contains(key)
1006 } else {
1007 entries().any(|entry| {
1008 matches!(
1009 entry.path,
1010 [PathPart::Part("gamemodes"), PathPart::Part(declared), ..]
1011 if *declared == *mode
1012 )
1013 })
1014 };
1015 if !inherits {
1016 return None;
1017 }
1018 scan_exact(&[
1019 PathPart::Part("gamemodes"),
1020 PathPart::Part("general"),
1021 PathPart::Part(key),
1022 ])
1023 }
1024
1025 fn scan_lookup(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
1026 scan_exact(path).or_else(|| scan_mode_inherited(path))
1027 }
1028
1029 #[test]
1030 fn index_matches_scan_for_every_table_path() {
1031 let mut checked = 0usize;
1032 for entry in entries() {
1033 assert_eq!(
1034 scan_lookup(entry.path).map(std::ptr::from_ref),
1035 lookup(entry.path).map(std::ptr::from_ref),
1036 "path {}",
1037 path_string(entry.path),
1038 );
1039 checked += 1;
1040 if let [
1041 PathPart::Part("gamemodes"),
1042 PathPart::Part(_),
1043 PathPart::Part(key),
1044 ] = entry.path
1045 {
1046 for mode in declared_modes().iter().copied().chain(["elimination"]) {
1047 let path = [
1048 PathPart::Part("gamemodes"),
1049 PathPart::Part(mode),
1050 PathPart::Part(key),
1051 ];
1052 assert_eq!(
1053 scan_lookup(&path).map(std::ptr::from_ref),
1054 lookup(&path).map(std::ptr::from_ref),
1055 "path {}",
1056 path_string(&path),
1057 );
1058 checked += 1;
1059 }
1060 }
1061 }
1062
1063 let non_resolving: &[&[PathPart<'_>]] = &[
1064 &[],
1065 &[PathPart::Part("main")],
1066 &[PathPart::Part("lobby")],
1067 &[PathPart::Part("unknownNamespace"), PathPart::Part("key")],
1068 &[PathPart::Part("lobby"), PathPart::Part("unknownKey")],
1069 &[PathPart::Part("gamemodes"), PathPart::Part("general")],
1070 &[
1071 PathPart::Part("gamemodes"),
1072 PathPart::Part("undeclaredMode"),
1073 PathPart::Part("enabledMaps"),
1074 ],
1075 &[
1076 PathPart::Part("gamemodes"),
1077 PathPart::Part("elimination"),
1078 PathPart::Part("healthPackRespawnTime"),
1079 ],
1080 &[PathPart::Part("heroes"), PathPart::Team],
1081 ];
1082 for path in non_resolving {
1083 assert_eq!(
1084 scan_lookup(path).map(std::ptr::from_ref),
1085 lookup(path).map(std::ptr::from_ref),
1086 "path {}",
1087 path_string(path),
1088 );
1089 checked += 1;
1090 }
1091 for entry in entries() {
1092 let mismatched: Vec<PathPart<'_>> = entry
1093 .path
1094 .iter()
1095 .map(|part| match part {
1096 PathPart::Part(_) | PathPart::Team => PathPart::Hero,
1097 PathPart::Hero => PathPart::Team,
1098 })
1099 .collect();
1100 if mismatched == *entry.path {
1101 continue;
1102 }
1103 assert_eq!(
1104 scan_lookup(&mismatched).map(std::ptr::from_ref),
1105 lookup(&mismatched).map(std::ptr::from_ref),
1106 "path {}",
1107 path_string(&mismatched),
1108 );
1109 checked += 1;
1110 }
1111 assert!(checked > 500, "derived coverage was thin: {checked} paths");
1112 }
1113
1114 #[test]
1115 fn key_spellings_match_scan_for_every_table_parent() {
1116 fn scan_key_spellings(parent: &[PathPart<'_>]) -> Vec<&'static str> {
1117 let mut spellings: Vec<&'static str> = entries()
1118 .filter(|entry| {
1119 entry.path.len() == parent.len() + 1
1120 && entry.path[..parent.len()] == *parent
1121 && !entry.workshop_name.contains("%1$s")
1122 })
1123 .map(|entry| entry.workshop_name)
1124 .collect();
1125 let [PathPart::Part("gamemodes"), PathPart::Part(mode)] = parent else {
1126 return spellings;
1127 };
1128 if *mode == "general" {
1129 return spellings;
1130 }
1131 if *mode == "elimination" {
1132 spellings.extend(entries().filter_map(|entry| {
1133 let [
1134 PathPart::Part("gamemodes"),
1135 PathPart::Part("general"),
1136 PathPart::Part(key),
1137 ] = entry.path
1138 else {
1139 return None;
1140 };
1141 ELIMINATION_GENERAL_KEYS
1142 .contains(key)
1143 .then_some(entry.workshop_name)
1144 }));
1145 } else if entries().any(|entry| {
1146 matches!(
1147 entry.path,
1148 [PathPart::Part("gamemodes"), PathPart::Part(declared), ..]
1149 if *declared == *mode
1150 )
1151 }) {
1152 spellings.extend(entries().filter_map(|entry| {
1153 let [
1154 PathPart::Part("gamemodes"),
1155 PathPart::Part("general"),
1156 PathPart::Part(_),
1157 ] = entry.path
1158 else {
1159 return None;
1160 };
1161 (!entry.workshop_name.contains("%1$s")).then_some(entry.workshop_name)
1162 }));
1163 }
1164 spellings
1165 }
1166
1167 let mut parents: Vec<Vec<PathPart<'_>>> = Vec::new();
1168 for entry in entries() {
1169 for len in 0..entry.path.len() {
1170 let parent = entry.path[..len].to_vec();
1171 if !parents.contains(&parent) {
1172 parents.push(parent);
1173 }
1174 }
1175 }
1176 parents.push(vec![PathPart::Part("unknownNamespace")]);
1177 parents.push(vec![
1178 PathPart::Part("gamemodes"),
1179 PathPart::Part("undeclaredMode"),
1180 ]);
1181 for parent in &parents {
1182 assert_eq!(
1183 scan_key_spellings(parent),
1184 key_spellings(parent),
1185 "parent {}",
1186 path_string(parent),
1187 );
1188 }
1189 }
1190}