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