Skip to main content

workshop_rs/settings/
table.rs

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/// A localized settings name declared by the reviewed locale data (#448).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct LocalizedSettingName {
32    /// The locale-data section the name belongs to (`namespaces`,
33    /// `labels`, `modes`, `heroes`, `maps`, `teams`, `tokens`, `enums`).
34    pub section: &'static str,
35    /// The en-US display name this row localizes.
36    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
52/// Every localized settings name the reviewed locale data declares, in
53/// deterministic section/name/locale order.
54pub 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}