Skip to main content

workshop_rs/settings/
table.rs

1//! Fixture-backed settings emission table (#86).
2//!
3//! SOURCE: observed from the pinned oracle 9.7.10 en-US output of the
4//! oracle-success settings programs (`compile.workshop` settings section of
5//! the committed snapshots pixelart/santa/broken-weapons/client-to-server,
6//! plus the parabola/crosshair/inputhud oracle runs) at OverPy commit
7//! `eea67ad`. This is observed-behavior data, not copied OverPy source
8//! (LICENSE-BOUNDARY policy). Additions to the table (e.g. the acquired
9//! candidate snapshots) are data-only.
10
11use serde::Deserialize;
12use serde_json::Value;
13use std::sync::OnceLock;
14
15use super::PathPart::{self, Hero, Part, Team};
16
17/// Locale-specific settings names generated from the reviewed Workshop data
18/// export. The projection contains every reviewed locale as data; adding a
19/// locale changes this file, not the parser or emitter architecture.
20const LOCALE_DATA: &str = include_str!("data/locales.json");
21
22fn locale_data() -> &'static Value {
23    static DATA: OnceLock<Value> = OnceLock::new();
24    DATA.get_or_init(|| {
25        serde_json::from_str(LOCALE_DATA).expect("generated settings locale data is valid JSON")
26    })
27}
28
29/// Resolve a settings display name from the generated locale corpus.
30///
31/// The English table names are intentionally not duplicated in the locale
32/// data. A missing entry means the target locale is not covered and callers
33/// must preserve the explicit missing-mapping contract.
34pub(crate) fn localized_name(locale: &str, section: &str, english: &str) -> Option<&'static str> {
35    let data = locale_data();
36    let aliases = data.get(section)?.get(english)?.as_object()?;
37    aliases.get(locale).and_then(Value::as_str).or_else(|| {
38        aliases.iter().find_map(|(known, value)| {
39            known
40                .eq_ignore_ascii_case(locale)
41                .then(|| value.as_str())
42                .flatten()
43        })
44    })
45}
46
47/// A localized settings name declared by the reviewed locale data (#448).
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct LocalizedSettingName {
50    /// The locale-data section the name belongs to (`namespaces`,
51    /// `labels`, `modes`, `heroes`, `maps`, `teams`, `tokens`, `enums`).
52    pub section: &'static str,
53    /// The en-US display name this row localizes.
54    pub english: &'static str,
55    /// The declaring locale.
56    pub locale: &'static str,
57    /// The declared spelling.
58    pub spelling: &'static str,
59}
60
61/// Sections of the generated locale data that carry per-locale spellings.
62const LOCALE_SECTIONS: &[&str] = &[
63    "enums",
64    "heroes",
65    "labels",
66    "maps",
67    "modes",
68    "namespaces",
69    "teams",
70    "tokens",
71];
72
73/// Every localized settings name the reviewed locale data declares, in
74/// deterministic section/name/locale order.
75pub fn localized_setting_names() -> &'static [LocalizedSettingName] {
76    static NAMES: OnceLock<Vec<LocalizedSettingName>> = OnceLock::new();
77    NAMES.get_or_init(|| {
78        let data = locale_data();
79        let mut names = Vec::new();
80        for section in LOCALE_SECTIONS {
81            let Some(rows) = data.get(section).and_then(Value::as_object) else {
82                continue;
83            };
84            for (english, row) in rows {
85                let Some(aliases) = row.as_object() else {
86                    continue;
87                };
88                for (locale, value) in aliases {
89                    let Some(spelling) = value.as_str() else {
90                        continue;
91                    };
92                    names.push(LocalizedSettingName {
93                        section,
94                        english,
95                        locale,
96                        spelling,
97                    });
98                }
99            }
100        }
101        names
102    })
103}
104
105/// A leaf key kind: how a settings leaf renders and validates.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub(crate) enum KeyKind {
108    /// A presence-only extension setting.
109    Flag,
110    /// A quoted string (`Description: "..."`).
111    String,
112    /// A boolean rendered `On`/`Off`.
113    Bool,
114    /// A boolean rendered `Yes`/`No`.
115    YesNo,
116    /// A boolean carrier rendered through a source-supported true-value enum
117    /// token. The false value remains unsupported until independently sourced.
118    BoolEnum(&'static str),
119    /// A plain number.
120    Number,
121    /// A number rendered with a `%` suffix (`Respawn Time Scalar: 30%`).
122    Percent,
123    /// A string-valued enumeration with a per-domain member map
124    /// (`Enum(domain)`).
125    Enum(&'static str),
126    /// A list of map names (`enabled maps`).
127    ListMap,
128    /// A list of hero names (`enabled heroes`).
129    ListHero,
130}
131
132/// One table entry: an exact key path, its workshop name, and its kind.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub(crate) struct TableEntry {
135    pub(crate) path: &'static [PathPart<'static>],
136    pub(crate) workshop_name: &'static str,
137    pub(crate) kind: KeyKind,
138}
139
140macro_rules! entry {
141    ($path:expr, $name:expr, $kind:expr) => {
142        TableEntry {
143            path: &$path,
144            workshop_name: $name,
145            kind: $kind,
146        }
147    };
148}
149
150/// The fixture-backed settings surface.
151///
152/// Slot sets (source-backed): teams {allTeams}, heroes {mei} config groups +
153/// the 10 ListHero names. `enabled: true` is not source-backed; it renders with
154/// no prefix. Keys outside this table (e.g. team1Slots, scoreToWin,
155/// gamemodeStartTrigger, spawnHealthPacks, healthPackRespawnTime%,
156/// abilityCooldown%, healingReceived%, primaryFireKb%, enableSpawningWithUlt,
157/// resetPlayersAfterGoalScored, scoreLeadToWin, gameLengthInSec,
158/// heroes.<team>.general, roleLimit under general, heroLimit under a named
159/// mode) are `settings-unknown-key` at validation (only source-backed in
160/// oracle-failing programs; corpus-bounded).
161pub(crate) static ENTRIES: &[TableEntry] = &[
162    // main
163    entry!(
164        [Part("main"), Part("description")],
165        "Description",
166        KeyKind::String
167    ),
168    entry!(
169        [Part("main"), Part("modeName")],
170        "Mode Name",
171        KeyKind::String
172    ),
173    // lobby
174    entry!(
175        [Part("lobby"), Part("ffaSlots")],
176        "Max FFA Players",
177        KeyKind::Number
178    ),
179    entry!(
180        [Part("lobby"), Part("mapRotation")],
181        "Map Rotation",
182        KeyKind::Enum("mapRotation")
183    ),
184    entry!(
185        [Part("lobby"), Part("spectatorSlots")],
186        "Max Spectators",
187        KeyKind::Number
188    ),
189    entry!(
190        [Part("lobby"), Part("enableMatchVoiceChat")],
191        "Match Voice Chat",
192        KeyKind::BoolEnum("matchVoiceChat")
193    ),
194    entry!(
195        [Part("lobby"), Part("team1Slots")],
196        "Max Team 1 Players",
197        KeyKind::Number
198    ),
199    entry!(
200        [Part("lobby"), Part("team2Slots")],
201        "Max Team 2 Players",
202        KeyKind::Number
203    ),
204    entry!(
205        [Part("lobby"), Part("returnToLobby")],
206        "Return To Lobby",
207        KeyKind::Enum("returnToLobby")
208    ),
209    entry!(
210        [Part("lobby"), Part("allowPlayersInQueue")],
211        "Allow Players Who Are In Queue",
212        KeyKind::YesNo
213    ),
214    entry!(
215        [Part("lobby"), Part("swapTeamsAfterMatch")],
216        "Swap Teams After Match",
217        KeyKind::YesNo
218    ),
219    // gamemodes.<mode> — per-key subsets (exact-path entries, #86):
220    // enabledMaps under modes {assault, control, escort, hybrid, skirmish,
221    // ffa}; enabled/roleLimit/enableCompetitiveRules under {assault, control,
222    // escort, hybrid}; heroLimit/respawnTime%/enableHeroSwitching/
223    // enableRandomHeroes under general only (general is a literal group name,
224    // not a mode slot).
225    entry!(
226        [Part("gamemodes"), Part("assault"), Part("enabled")],
227        "enabled",
228        KeyKind::Bool
229    ),
230    entry!(
231        [Part("gamemodes"), Part("control"), Part("enabled")],
232        "enabled",
233        KeyKind::Bool
234    ),
235    entry!(
236        [Part("gamemodes"), Part("escort"), Part("enabled")],
237        "enabled",
238        KeyKind::Bool
239    ),
240    entry!(
241        [Part("gamemodes"), Part("hybrid"), Part("enabled")],
242        "enabled",
243        KeyKind::Bool
244    ),
245    entry!(
246        [Part("gamemodes"), Part("assault"), Part("enabledMaps")],
247        "enabled maps",
248        KeyKind::ListMap
249    ),
250    entry!(
251        [Part("gamemodes"), Part("control"), Part("enabledMaps")],
252        "enabled maps",
253        KeyKind::ListMap
254    ),
255    entry!(
256        [Part("gamemodes"), Part("escort"), Part("enabledMaps")],
257        "enabled maps",
258        KeyKind::ListMap
259    ),
260    entry!(
261        [Part("gamemodes"), Part("hybrid"), Part("enabledMaps")],
262        "enabled maps",
263        KeyKind::ListMap
264    ),
265    entry!(
266        [Part("gamemodes"), Part("skirmish"), Part("enabledMaps")],
267        "enabled maps",
268        KeyKind::ListMap
269    ),
270    entry!(
271        [Part("gamemodes"), Part("assault"), Part("disabledMaps")],
272        "disabled maps",
273        KeyKind::ListMap
274    ),
275    entry!(
276        [Part("gamemodes"), Part("skirmish"), Part("disabledMaps")],
277        "disabled maps",
278        KeyKind::ListMap
279    ),
280    entry!(
281        [Part("gamemodes"), Part("ffa"), Part("enabledMaps")],
282        "enabled maps",
283        KeyKind::ListMap
284    ),
285    entry!(
286        [Part("gamemodes"), Part("tdm"), Part("enabledMaps")],
287        "enabled maps",
288        KeyKind::ListMap
289    ),
290    entry!(
291        [Part("gamemodes"), Part("assault"), Part("roleLimit")],
292        "Limit Roles",
293        KeyKind::Enum("roleLimit")
294    ),
295    entry!(
296        [Part("gamemodes"), Part("control"), Part("roleLimit")],
297        "Limit Roles",
298        KeyKind::Enum("roleLimit")
299    ),
300    entry!(
301        [Part("gamemodes"), Part("escort"), Part("roleLimit")],
302        "Limit Roles",
303        KeyKind::Enum("roleLimit")
304    ),
305    entry!(
306        [Part("gamemodes"), Part("hybrid"), Part("roleLimit")],
307        "Limit Roles",
308        KeyKind::Enum("roleLimit")
309    ),
310    entry!(
311        [Part("gamemodes"), Part("general"), Part("roleLimit")],
312        "Limit Roles",
313        KeyKind::Enum("roleLimit")
314    ),
315    entry!(
316        [
317            Part("gamemodes"),
318            Part("assault"),
319            Part("enableCompetitiveRules")
320        ],
321        "Competitive Rules",
322        KeyKind::Bool
323    ),
324    entry!(
325        [
326            Part("gamemodes"),
327            Part("control"),
328            Part("enableCompetitiveRules")
329        ],
330        "Competitive Rules",
331        KeyKind::Bool
332    ),
333    entry!(
334        [
335            Part("gamemodes"),
336            Part("escort"),
337            Part("enableCompetitiveRules")
338        ],
339        "Competitive Rules",
340        KeyKind::Bool
341    ),
342    entry!(
343        [
344            Part("gamemodes"),
345            Part("hybrid"),
346            Part("enableCompetitiveRules")
347        ],
348        "Competitive Rules",
349        KeyKind::Bool
350    ),
351    // gamemodes.general
352    entry!(
353        [
354            Part("gamemodes"),
355            Part("general"),
356            Part("enableCompetitiveRules")
357        ],
358        "Competitive Rules",
359        KeyKind::Bool
360    ),
361    entry!(
362        [Part("gamemodes"), Part("general"), Part("enablePerks")],
363        "Enable Perks",
364        KeyKind::Bool
365    ),
366    entry!(
367        [Part("gamemodes"), Part("general"), Part("heroLimit")],
368        "Hero Limit",
369        KeyKind::Enum("heroLimit")
370    ),
371    entry!(
372        [Part("gamemodes"), Part("general"), Part("respawnTime%")],
373        "Respawn Time Scalar",
374        KeyKind::Percent
375    ),
376    entry!(
377        [
378            Part("gamemodes"),
379            Part("general"),
380            Part("enableHeroSwitching")
381        ],
382        "Allow Hero Switching",
383        KeyKind::Bool
384    ),
385    entry!(
386        [
387            Part("gamemodes"),
388            Part("general"),
389            Part("enableRandomHeroes")
390        ],
391        "Respawn As Random Hero",
392        KeyKind::Bool
393    ),
394    entry!(
395        [
396            Part("gamemodes"),
397            Part("general"),
398            Part("gamemodeStartTrigger")
399        ],
400        "Game Mode Start",
401        KeyKind::Enum("gamemodeStartTrigger")
402    ),
403    entry!(
404        [
405            Part("gamemodes"),
406            Part("assault"),
407            Part("gamemodeStartTrigger")
408        ],
409        "Game Mode Start",
410        KeyKind::Enum("gamemodeStartTrigger")
411    ),
412    entry!(
413        [
414            Part("gamemodes"),
415            Part("assault"),
416            Part("tankPassiveHealthBonus")
417        ],
418        "Tank Role Passive Health Bonus",
419        KeyKind::Enum("tankPassiveHealthBonus")
420    ),
421    entry!(
422        [Part("gamemodes"), Part("general"), Part("spawnHealthPacks")],
423        "Spawn Health Packs",
424        KeyKind::Enum("spawnHealthPacks")
425    ),
426    // heroes.<team>
427    entry!(
428        [Part("heroes"), Team, Part("enabledHeroes")],
429        "enabled heroes",
430        KeyKind::ListHero
431    ),
432    entry!(
433        [Part("heroes"), Team, Part("disabledHeroes")],
434        "disabled heroes",
435        KeyKind::ListHero
436    ),
437    entry!(
438        [Part("heroes"), Part("general"), Part("disabledHeroes")],
439        "disabled heroes",
440        KeyKind::ListHero
441    ),
442    // heroes.<team>.<hero> config groups
443    entry!(
444        [Part("heroes"), Team, Hero, Part("enablePrimaryFire")],
445        "Primary Fire",
446        KeyKind::Bool
447    ),
448    entry!(
449        [Part("heroes"), Team, Hero, Part("enableSecondaryFire")],
450        "Secondary Fire",
451        KeyKind::Bool
452    ),
453    entry!(
454        [Part("heroes"), Team, Hero, Part("enableAbility1")],
455        "Ability 1",
456        KeyKind::Bool
457    ),
458    entry!(
459        [Part("heroes"), Team, Hero, Part("enableAbility2")],
460        "Ability 2",
461        KeyKind::Bool
462    ),
463    entry!(
464        [Part("heroes"), Team, Hero, Part("health%")],
465        "Health",
466        KeyKind::Percent
467    ),
468    entry!(
469        [Part("heroes"), Team, Hero, Part("passiveUltGen%")],
470        "Ultimate Generation - Passive Blizzard",
471        KeyKind::Percent
472    ),
473    entry!(
474        [Part("heroes"), Team, Hero, Part("combatUltGen%")],
475        "Ultimate Generation - Combat Blizzard",
476        KeyKind::Percent
477    ),
478];
479
480/// A slot name mapping (key -> localized workshop name).
481#[derive(Debug, Clone, Copy)]
482pub(crate) struct NameMap {
483    pub(crate) key: &'static str,
484    pub(crate) name: &'static str,
485}
486
487const fn named(key: &'static str, name: &'static str) -> NameMap {
488    NameMap { key, name }
489}
490
491/// Game-mode names (source-backed: assault, control, escort, hybrid, skirmish,
492/// ffa, tdm, general).
493pub(crate) static MODE_NAMES: &[NameMap] = &[
494    named("assault", "Assault"),
495    named("control", "Control"),
496    named("escort", "Escort"),
497    named("hybrid", "Hybrid"),
498    named("skirmish", "Skirmish"),
499    named("ffa", "Deathmatch"),
500    named("tdm", "Team Deathmatch"),
501    named("general", "General"),
502];
503
504/// Map names inside `enabledMaps` lists.
505pub(crate) static MAP_NAMES: &[NameMap] = &[
506    named("workshopIsland", "Workshop Island"),
507    named("kingsRowWinter", "King's Row Winter"),
508];
509
510/// Hero names inside hero lists and hero-config groups.
511pub(crate) static HERO_NAMES: &[NameMap] = &[
512    named("anran", "Anran"),
513    named("ana", "Ana"),
514    named("ashe", "Ashe"),
515    named("bastion", "Bastion"),
516    named("baptiste", "Baptiste"),
517    named("brigitte", "Brigitte"),
518    named("cassidy", "Cassidy"),
519    named("dmon", "D.Mon"),
520    named("domina", "Domina"),
521    named("dva", "D.Va"),
522    named("doomfist", "Doomfist"),
523    named("echo", "Echo"),
524    named("emre", "Emre"),
525    named("freja", "Freja"),
526    named("genji", "Genji"),
527    named("hanzo", "Hanzo"),
528    named("moira", "Moira"),
529    named("reinhardt", "Reinhardt"),
530    named("hazard", "Hazard"),
531    named("illari", "Illari"),
532    named("juno", "Juno"),
533    named("jetpackCat", "Jetpack Cat"),
534    named("junkerQueen", "Junker Queen"),
535    named("junkrat", "Junkrat"),
536    named("kiriko", "Kiriko"),
537    named("lucio", "Lúcio"),
538    named("mauga", "Mauga"),
539    named("mercy", "Mercy"),
540    named("mizuki", "Mizuki"),
541    named("orisa", "Orisa"),
542    named("pharah", "Pharah"),
543    named("reaper", "Reaper"),
544    named("roadhog", "Roadhog"),
545    named("shion", "Shion"),
546    named("sierra", "Sierra"),
547    named("sigma", "Sigma"),
548    named("ramattra", "Ramattra"),
549    named("lifeweaver", "Lifeweaver"),
550    named("sojourn", "Sojourn"),
551    named("soldier", "Soldier: 76"),
552    named("sombra", "Sombra"),
553    named("symmetra", "Symmetra"),
554    named("torbjorn", "Torbjörn"),
555    named("tracer", "Tracer"),
556    named("venture", "Venture"),
557    named("widowmaker", "Widowmaker"),
558    named("winston", "Winston"),
559    named("wuyang", "Wuyang"),
560    named("wreckingBall", "Wrecking Ball"),
561    named("zarya", "Zarya"),
562    named("zenyatta", "Zenyatta"),
563    named("mei", "Mei"),
564];
565
566include!("data/generated_map_entries.rs");
567include!("data/generated_hero_entries.rs");
568include!("data/generated_mode_entries.rs");
569
570/// Team names inside `heroes` (source-backed: allTeams).
571pub(crate) static TEAM_NAMES: &[NameMap] = &[
572    named("allTeams", "General"),
573    named("team1", "Team 1"),
574    named("team2", "Team 2"),
575];
576
577/// An enum domain member (domain -> localized workshop name).
578#[derive(Debug, Clone, Copy)]
579pub(crate) struct EnumMember {
580    pub(crate) domain: &'static str,
581    pub(crate) member: &'static str,
582    pub(crate) name: &'static str,
583}
584
585const fn enum_member(domain: &'static str, member: &'static str, name: &'static str) -> EnumMember {
586    EnumMember {
587        domain,
588        member,
589        name,
590    }
591}
592
593include!("data/generated_entries.rs");
594include!("data/generated_hero_settings.rs");
595
596/// Fixture-owned canonical enum member names. Additional reviewed
597/// Workshop-data export members are retained through
598/// `projection_reconciliation.json`, which maps their source identities into
599/// these canonical domains without replacing fixture-backed display names.
600pub(crate) static ENUM_MEMBERS: &[EnumMember] = &[
601    enum_member("mapRotation", "afterAGame", "After A Game"),
602    enum_member("mapRotation", "afterMirrorMatch", "After A Mirror Match"),
603    enum_member("mapRotation", "paused", "Paused"),
604    enum_member("matchVoiceChat", "enabled", "Enabled"),
605    enum_member("returnToLobby", "never", "Never"),
606    enum_member("returnToLobby", "afterAGame", "After A Game"),
607    enum_member("returnToLobby", "afterMirrorMatch", "After A Mirror Match"),
608    enum_member("gamemodeStartTrigger", "immediately", "Immediately"),
609    enum_member("gamemodeStartTrigger", "manual", "Manual"),
610    enum_member("gamemodeStartTrigger", "allSlotsFilled", "All Slots Filled"),
611    enum_member("spawnHealthPacks", "disabled", "Disabled"),
612    enum_member("spawnHealthPacks", "modeDependent", "Determined By Mode"),
613    enum_member("spawnHealthPacks", "enabled", "Enabled"),
614    enum_member("roleLimit", "2OfEachRolePerTeam", "2 Of Each Role Per Team"),
615    enum_member(
616        "roleLimit",
617        "1Tank2Offense2Support",
618        "1 Tank 2 Offense 2 Support",
619    ),
620    enum_member("roleLimit", "off", "Off"),
621    enum_member("tankPassiveHealthBonus", "alwaysEnabled", "Always Enabled"),
622    enum_member("tankPassiveHealthBonus", "disabled", "Disabled"),
623    enum_member(
624        "tankPassiveHealthBonus",
625        "1Tank2Offense2Support",
626        "1 Tank 2 Offense 2 Support",
627    ),
628    enum_member("heroLimit", "off", "Off"),
629    enum_member("heroLimit", "1PerTeam", "1 Per Team"),
630    enum_member("heroLimit", "2PerTeam", "2 Per Team"),
631    enum_member("heroLimit", "1PerGame", "1 Per Game"),
632    enum_member("heroLimit", "2PerGame", "2 Per Game"),
633];
634
635/// Look up a settings leaf entry by its exact path.
636///
637/// `gamemodes.<mode>` groups inherit the `gamemodes.general` leaf set: the
638/// pinned oracle's settings schema merges `general.values` into every declared
639/// mode, with `elimination` inheriting only [`ELIMINATION_GENERAL_KEYS`]
640/// (source: `computeCustomGameSettingsSchema` in the pinned 9.7.10 oracle).
641/// The fallback resolves to the same `general` table entry, so an inherited
642/// per-mode path shares that entry's canonical identity.
643pub(crate) fn lookup(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
644    exact_entry(path).or_else(|| mode_inherited_entry(path))
645}
646
647fn exact_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
648    entries().find(|entry| {
649        entry.path.len() == path.len() && entry.path.iter().zip(path.iter()).all(|(a, b)| a == b)
650    })
651}
652
653/// The `gamemodes.general` leaf keys `elimination` inherits (the pinned
654/// oracle's restricted copy list; every other declared mode inherits the full
655/// general set).
656static ELIMINATION_GENERAL_KEYS: &[&str] = &[
657    "disabledMaps",
658    "enableEnemyHealthBars",
659    "enableKillCam",
660    "enableKillFeed",
661    "enableSkins",
662    "enabledMaps",
663    "gamemodeStartTrigger",
664    "healthPackRespawnTime%",
665    "perkEliminationCatchupLevelAmount%",
666    "perkGeneration%",
667    "spawnHealthPacks",
668    "teamOverlay",
669];
670
671pub(crate) fn mode_inherited_entry(path: &[PathPart<'_>]) -> Option<&'static TableEntry> {
672    let [
673        PathPart::Part("gamemodes"),
674        PathPart::Part(mode),
675        PathPart::Part(key),
676    ] = path
677    else {
678        return None;
679    };
680    if *mode == "general" {
681        return None;
682    }
683    let inherits = if *mode == "elimination" {
684        ELIMINATION_GENERAL_KEYS.contains(key)
685    } else {
686        // A mode with declared entries is a reviewed mode slot and inherits
687        // the general leaf set.
688        entries().any(|entry| {
689            matches!(
690                entry.path,
691                [PathPart::Part("gamemodes"), PathPart::Part(declared), ..] if *declared == *mode
692            )
693        })
694    };
695    if !inherits {
696        return None;
697    }
698    exact_entry(&[
699        PathPart::Part("gamemodes"),
700        PathPart::Part("general"),
701        PathPart::Part(key),
702    ])
703}
704
705/// Iterate the reviewed settings inventory with the hand-written projection
706/// taking precedence over the generated export projection. Duplicate paths
707/// are represented once in the semantic catalog while the parser and emitter
708/// continue to use the same lookup table.
709pub(crate) fn entries() -> impl Iterator<Item = &'static TableEntry> {
710    deduplicated_entries(ENTRIES.iter().chain(GENERATED_ENTRIES.iter()))
711}
712
713/// Iterate both catalog projections without applying effective lookup
714/// precedence. The semantic validator uses this to compare duplicate paths
715/// instead of allowing `entries()` to hide stale or conflicting data.
716pub(crate) fn raw_entries() -> impl Iterator<Item = ProjectedEntry> {
717    ENTRIES
718        .iter()
719        .map(|entry| ProjectedEntry {
720            source: ProjectionSource::FixtureTable,
721            entry,
722        })
723        .chain(GENERATED_ENTRIES.iter().map(|entry| ProjectedEntry {
724            source: ProjectionSource::WorkshopDataExport,
725            entry,
726        }))
727}
728
729/// One setting leaf before effective lookup resolves duplicated projections.
730#[derive(Debug, Clone, Copy)]
731pub(crate) struct ProjectedEntry {
732    pub(crate) source: ProjectionSource,
733    pub(crate) entry: &'static TableEntry,
734}
735
736/// The source that supplied a raw settings projection entry.
737#[derive(Debug, Clone, Copy)]
738pub(crate) enum ProjectionSource {
739    FixtureTable,
740    WorkshopDataExport,
741}
742
743impl ProjectionSource {
744    pub(crate) const fn label(self) -> &'static str {
745        match self {
746            Self::FixtureTable => "fixture table",
747            Self::WorkshopDataExport => "Workshop-data export",
748        }
749    }
750}
751
752fn deduplicated_entries(
753    entries: impl Iterator<Item = &'static TableEntry>,
754) -> impl Iterator<Item = &'static TableEntry> {
755    let mut paths = std::collections::HashSet::new();
756    entries.filter(move |entry| paths.insert(path_string(entry.path)))
757}
758
759pub(crate) fn is_generated_entry(entry: &TableEntry) -> bool {
760    GENERATED_ENTRIES
761        .iter()
762        .any(|candidate| std::ptr::eq(candidate, entry))
763}
764
765/// Map the existing hero-settings leaf keys to canonical gameplay slots.
766/// The setting tree remains the owner of the keys; display names are resolved
767/// from the gameplay catalog by the parser/emitter when a hero context exists.
768pub(crate) fn ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
769    match path.last() {
770        Some(PathPart::Part("ability1Cooldown%" | "enableAbility1")) => Some("ability1"),
771        Some(PathPart::Part("ability2Cooldown%" | "enableAbility2")) => Some("ability2"),
772        Some(PathPart::Part("ability3Cooldown%" | "enableAbility3")) => Some("ability3"),
773        Some(PathPart::Part(
774            "secondaryFireCooldown%"
775            | "secondaryFireEnergyChargeRate%"
776            | "secondaryFireMaximumTime%"
777            | "secondaryFireRechargeRate%"
778            | "enableSecondaryFire"
779            | "enableGenericSecondaryFire",
780        )) => Some("secondaryFire"),
781        Some(PathPart::Part("combatUltGen%" | "passiveUltGen%" | "ultGen%" | "enableUlt")) => {
782            Some("ultimate")
783        }
784        Some(PathPart::Part("enablePassive")) => Some("passive"),
785        _ => None,
786    }
787}
788
789/// Resolve a source-backed hero-specific setting label.
790pub(crate) fn hero_setting_name(hero: &str, key: &str, locale: &str) -> Option<&'static str> {
791    // A producer alias is the spelling pinned OverPy writes, so it is emitted
792    // in preference to the export label.
793    hero_setting_aliases()
794        .iter()
795        .find(|alias| {
796            alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
797        })
798        .map(|alias| alias.display.as_str())
799        .or_else(|| {
800            GENERATED_HERO_SETTING_NAMES
801                .iter()
802                .find(|entry| entry.hero == hero && entry.key == key)
803                .and_then(|entry| entry.localized(locale))
804        })
805        .or_else(|| {
806            if locale.eq_ignore_ascii_case("en-US") {
807                GENERATED_HERO_SETTING_NAMES
808                    .iter()
809                    .find(|entry| entry.hero == hero && entry.key == key)
810                    .filter(|entry| {
811                        entry.locales.iter().any(|(known, value)| {
812                            known.eq_ignore_ascii_case(locale)
813                                && (value.trim().is_empty() || value.starts_with(' '))
814                        })
815                    })
816                    .map(|entry| entry.key)
817            } else {
818                None
819            }
820        })
821}
822
823/// Parse-side display match against every generated row for `(hero, key)`.
824/// Emission reads only the first row, but duplicate upstream rows carry
825/// additional reviewed spellings the parser must still accept. A producer
826/// alias shadows the export labels entirely: emission prefers the alias, so
827/// accepting a shadowed generated spelling would bind a display the emitter
828/// cannot reproduce.
829pub(crate) fn hero_setting_display_matches(
830    hero: &str,
831    key: &str,
832    locale: &str,
833    display: &str,
834) -> bool {
835    if hero_setting_has_alias(hero, key, locale) {
836        return false;
837    }
838    GENERATED_HERO_SETTING_NAMES
839        .iter()
840        .filter(|entry| entry.hero == hero && entry.key == key)
841        .filter_map(|entry| entry.localized(locale))
842        .any(|name| names_eq(name, display))
843}
844
845/// The `%1$s` composition template a hero-scoped leaf key carries: the
846/// generated export's placeholder form, which the emitter fills with the
847/// hero's gameplay ability name when no reviewed per-hero label exists.
848pub(crate) fn hero_key_template(key: &str) -> Option<&'static str> {
849    GENERATED_ENTRIES
850        .iter()
851        .find(|entry| {
852            matches!(
853                entry.path,
854                [
855                    PathPart::Part("heroes"),
856                    PathPart::Team,
857                    PathPart::Hero,
858                    PathPart::Part(candidate)
859                ] if *candidate == key
860            )
861        })
862        .map(|entry| entry.workshop_name)
863        .filter(|name| name.contains("%1$s"))
864}
865
866#[derive(Deserialize)]
867struct HeroSettingAlias {
868    hero: String,
869    key: String,
870    locale: String,
871    display: String,
872}
873
874fn hero_setting_aliases() -> &'static [HeroSettingAlias] {
875    static ALIASES: OnceLock<Vec<HeroSettingAlias>> = OnceLock::new();
876    ALIASES.get_or_init(|| {
877        serde_json::from_str(include_str!("data/hero_setting_aliases.json"))
878            .expect("hero setting alias data is valid JSON")
879    })
880}
881
882/// Whether a reviewed producer alias exists for `(hero, key)` in `locale` —
883/// the alias shadows the export's generated labels and `%1$s` templates.
884pub(crate) fn hero_setting_has_alias(hero: &str, key: &str, locale: &str) -> bool {
885    hero_setting_aliases().iter().any(|alias| {
886        alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale)
887    })
888}
889
890/// Reviewed producer aliases observed in the pinned AI-PVE artifact. These
891/// labels omit the export's `倍率` suffix or use the producer's shorter
892/// ability label, but identify the same canonical setting path.
893pub(crate) fn hero_setting_alias(hero: &str, key: &str, locale: &str, display: &str) -> bool {
894    hero_setting_aliases().iter().any(|alias| {
895        alias.hero == hero
896            && alias.key == key
897            && alias.locale.eq_ignore_ascii_case(locale)
898            && names_eq(&alias.display, display)
899    })
900}
901
902fn name_in(maps: &[NameMap], key: &str) -> Option<&'static str> {
903    maps.iter().find(|m| m.key == key).map(|m| m.name)
904}
905
906/// Settings-name comparison: the Workshop client matches setting keys and
907/// value spellings without regard to case (Deltinteger's `TextToElement`
908/// settings matcher is `caseSensitive: false`; the pinned oracle's
909/// decompiler lowercases both sides). Whitespace differences in a reviewed
910/// spelling (`66号公路`/`66 号公路`, `1팀`/`1 팀`, #448) are likewise
911/// insignificant. Accents remain significant: `Chateau Guillard` does not
912/// match `Château Guillard`.
913pub(crate) fn names_eq(left: &str, right: &str) -> bool {
914    left == right
915        || left.to_lowercase() == right.to_lowercase()
916        || crate::catalog::normalize_spelling(left).to_lowercase()
917            == crate::catalog::normalize_spelling(right).to_lowercase()
918}
919
920/// Canonical map spellings for suggestion candidates (English names).
921pub(crate) fn map_spellings() -> impl Iterator<Item = &'static str> {
922    MAP_NAMES
923        .iter()
924        .chain(GENERATED_MAP_NAMES.iter())
925        .map(|m| m.name)
926}
927
928/// Canonical hero spellings for suggestion candidates (English names).
929pub(crate) fn hero_spellings() -> impl Iterator<Item = &'static str> {
930    HERO_NAMES
931        .iter()
932        .chain(GENERATED_HERO_NAMES.iter())
933        .map(|m| m.name)
934}
935
936/// Canonical team spellings for suggestion candidates (English names).
937pub(crate) fn team_spellings() -> impl Iterator<Item = &'static str> {
938    TEAM_NAMES.iter().map(|m| m.name)
939}
940
941/// Canonical member spellings of an enum domain for suggestion candidates.
942pub(crate) fn enum_spellings(domain: &str) -> impl Iterator<Item = &'static str> {
943    enum_members(domain).map(|member| member.name)
944}
945
946/// Canonical `workshop_name` spellings of the leaf entries valid directly
947/// under `parent`, including the `gamemodes.general` leaves a mode inherits.
948/// Mirrors `lookup`/`mode_inherited_entry`: `elimination` inherits only
949/// [`ELIMINATION_GENERAL_KEYS`], other modes inherit the full set only when
950/// they have declared entries, and `general` itself has no fallback.
951pub(crate) fn key_spellings(parent: &[PathPart<'_>]) -> Vec<&'static str> {
952    let mut spellings: Vec<&'static str> = entries()
953        .filter(|entry| {
954            entry.path.len() == parent.len() + 1
955                && entry.path[..parent.len()]
956                    .iter()
957                    .zip(parent.iter())
958                    .all(|(a, b)| a == b)
959                // `%1$s` placeholder names are emission templates, not
960                // spellings a source could carry.
961                && !entry.workshop_name.contains("%1$s")
962        })
963        .map(|entry| entry.workshop_name)
964        .collect();
965    let [PathPart::Part("gamemodes"), PathPart::Part(mode)] = parent else {
966        return spellings;
967    };
968    if *mode == "general" {
969        return spellings;
970    }
971    if *mode == "elimination" {
972        spellings.extend(entries().filter_map(|entry| {
973            let [
974                PathPart::Part("gamemodes"),
975                PathPart::Part("general"),
976                PathPart::Part(key),
977            ] = entry.path
978            else {
979                return None;
980            };
981            ELIMINATION_GENERAL_KEYS
982                .contains(key)
983                .then_some(entry.workshop_name)
984        }));
985    } else if entries().any(|entry| {
986        matches!(
987            entry.path,
988            [PathPart::Part("gamemodes"), PathPart::Part(declared), ..]
989                if *declared == *mode
990        )
991    }) {
992        spellings.extend(entries().filter_map(|entry| {
993            let [
994                PathPart::Part("gamemodes"),
995                PathPart::Part("general"),
996                PathPart::Part(_),
997            ] = entry.path
998            else {
999                return None;
1000            };
1001            (!entry.workshop_name.contains("%1$s")).then_some(entry.workshop_name)
1002        }));
1003    }
1004    spellings
1005}
1006
1007/// The localized name of a game mode.
1008pub(crate) fn mode_name(key: &str) -> Option<&'static str> {
1009    name_in(MODE_NAMES, key).or_else(|| name_in(GENERATED_MODE_NAMES, key))
1010}
1011
1012/// The localized name of a map.
1013pub(crate) fn map_name(key: &str) -> Option<&'static str> {
1014    name_in(MAP_NAMES, key).or_else(|| name_in(GENERATED_MAP_NAMES, key))
1015}
1016
1017/// The localized name of a hero.
1018pub(crate) fn hero_name(key: &str) -> Option<&'static str> {
1019    name_in(HERO_NAMES, key).or_else(|| name_in(GENERATED_HERO_NAMES, key))
1020}
1021
1022/// The localized name of a team.
1023pub(crate) fn team_name(key: &str) -> Option<&'static str> {
1024    name_in(TEAM_NAMES, key)
1025}
1026
1027/// The localized name of an enum member in a domain.
1028pub(crate) fn enum_name(domain: &str, member: &str) -> Option<&'static str> {
1029    ENUM_MEMBERS
1030        .iter()
1031        .find(|m| m.domain == domain && m.member == member)
1032        .map(|m| m.name)
1033        .or_else(|| {
1034            GENERATED_ENUM_MEMBERS
1035                .iter()
1036                .find(|m| m.domain == domain && m.member == member)
1037                .map(|m| m.name)
1038        })
1039}
1040
1041pub(crate) fn enum_members(domain: &str) -> impl Iterator<Item = &'static EnumMember> {
1042    ENUM_MEMBERS
1043        .iter()
1044        .chain(GENERATED_ENUM_MEMBERS.iter())
1045        .filter(move |member| member.domain == domain)
1046}
1047
1048/// A human-readable rendering of a path (diagnostics).
1049pub(crate) fn path_string(path: &[PathPart<'_>]) -> String {
1050    path.iter()
1051        .map(|part| match part {
1052            PathPart::Part(name) => (*name).to_string(),
1053            PathPart::Team => "<team>".to_string(),
1054            PathPart::Hero => "<hero>".to_string(),
1055        })
1056        .collect::<Vec<_>>()
1057        .join(".")
1058}