Skip to main content

workshop_rs/settings/schema/
projection.rs

1use super::super::reconciliation;
2use super::super::table::{self, KeyKind, TableEntry};
3use super::*;
4
5/// Project all currently reviewed table entries into the canonical semantic
6/// catalog. The table remains the single parser/emitter source; this
7/// projection supplies the stable semantic identity and typed facts consumed
8/// by callers.
9pub fn definitions() -> impl Iterator<Item = SettingDefinition> {
10    table::entries().map(SettingDefinition::from_entry)
11}
12
13/// Project one reviewed table entry into the canonical semantic definition.
14pub fn definition(path: &[PathPart<'_>]) -> Option<SettingDefinition> {
15    table::lookup(path).map(SettingDefinition::from_entry)
16}
17
18/// Find all definitions for a canonical concept identity.
19///
20/// A concept can intentionally have more than one target shape, so the
21/// result is an iterator rather than a single definition. This keeps normal
22/// consumers independent of the private table paths while retaining the
23/// target-specific schema facts.
24pub fn definitions_by_id(id: &SettingId) -> impl Iterator<Item = SettingDefinition> {
25    definitions().filter(move |definition| definition.id() == Some(id))
26}
27
28impl SettingDefinition {
29    fn from_entry(entry: &TableEntry) -> Self {
30        let scope = scope_for(entry.path);
31        let key = entry
32            .path
33            .last()
34            .and_then(|part| match part {
35                PathPart::Part(key) => Some(*key),
36                _ => None,
37            })
38            .unwrap_or("");
39        let target = target_for(entry.path);
40        let path = table::path_string(entry.path);
41        let enum_domain = match entry.kind {
42            KeyKind::BoolEnum(domain) | KeyKind::Enum(domain) => {
43                Some(reconciled_enum_domain(domain))
44            }
45            _ => None,
46        };
47        let domain = match entry.kind {
48            KeyKind::Enum(_) => SettingValueDomain::Enum {
49                domain: enum_domain.unwrap().to_string(),
50            },
51            _ => domain_for(entry.kind),
52        };
53        let identity = canonical_id(scope, key, entry.path)
54            .map(SettingIdentity::Known)
55            .unwrap_or(SettingIdentity::Unknown);
56        Self {
57            identity,
58            scope,
59            path,
60            path_parts: entry.path,
61            key,
62            target,
63            domain,
64            enum_domain,
65            presentation: SettingPresentation {
66                english_name: entry.workshop_name,
67                locale_section: "labels",
68            },
69            source: SettingSource {
70                kind: if table::is_generated_entry(entry) {
71                    SettingSourceKind::WorkshopDataExport
72                } else {
73                    SettingSourceKind::RawWorkshopFixture
74                },
75                source: if table::is_generated_entry(entry) {
76                    "workshop-data/workshop-data.json"
77                } else {
78                    "pinned raw Workshop settings fixtures"
79                },
80                reviewed: true,
81            },
82        }
83    }
84}
85
86fn scope_for(path: &[PathPart<'_>]) -> SettingScope {
87    match path.first() {
88        Some(PathPart::Part("main")) => SettingScope::Main,
89        Some(PathPart::Part("lobby")) => SettingScope::Lobby,
90        Some(PathPart::Part("gamemodes")) => SettingScope::GameModes,
91        Some(PathPart::Part("heroes")) => SettingScope::Heroes,
92        Some(PathPart::Part("extensions")) => SettingScope::Extensions,
93        Some(PathPart::Part("workshop")) => SettingScope::Workshop,
94        _ => SettingScope::Unknown,
95    }
96}
97
98fn target_for(path: &[PathPart<'_>]) -> TargetPattern {
99    match path {
100        // `gamemodes.general` leaves exist only as inherited members under
101        // each mode — there is no literal `general` group in source, so the
102        // target is a mode, not the global scope.
103        [PathPart::Part("gamemodes"), PathPart::Part("general"), ..] => TargetPattern::Mode(None),
104        [PathPart::Part("gamemodes"), PathPart::Part(mode), ..] => {
105            TargetPattern::Mode(Some((*mode).to_string()))
106        }
107        [PathPart::Part("gamemodes"), ..] => TargetPattern::Mode(None),
108        [PathPart::Part("heroes"), PathPart::Team, PathPart::Hero, ..] => {
109            target_for_hero(path, None)
110        }
111        [
112            PathPart::Part("heroes"),
113            PathPart::Part(team),
114            PathPart::Hero,
115            ..,
116        ] => target_for_hero(path, Some((*team).to_string())),
117        [PathPart::Part("heroes"), PathPart::Team, ..] => target_for_team(path, None),
118        [PathPart::Part("heroes"), PathPart::Part(team), ..] => {
119            target_for_team(path, Some((*team).to_string()))
120        }
121        [
122            PathPart::Part("main" | "lobby" | "extensions" | "workshop"),
123            ..,
124        ] => TargetPattern::Global,
125        _ => TargetPattern::Unknown,
126    }
127}
128
129fn target_for_team(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
130    match semantic_ability_slot_for_path(path) {
131        Some(slot) => TargetPattern::TeamAbility {
132            team,
133            slot: LogicalSlot::new(slot),
134            variant: None,
135        },
136        None => TargetPattern::Team(team),
137    }
138}
139
140fn target_for_hero(path: &[PathPart<'_>], team: Option<String>) -> TargetPattern {
141    let slot = semantic_ability_slot_for_path(path).map(str::to_string);
142    match slot {
143        Some(slot) => TargetPattern::HeroAbility {
144            team,
145            hero: None,
146            slot: LogicalSlot::new(slot),
147            variant: None,
148        },
149        None => TargetPattern::Hero { team, hero: None },
150    }
151}
152
153fn semantic_ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static str> {
154    match path.last() {
155        Some(PathPart::Part("enablePrimaryFire")) => Some("primaryFire"),
156        Some(PathPart::Part("enableGenericSecondaryFire")) => Some("secondaryFire"),
157        Some(PathPart::Part("enablePassiveUnlimitedFuel")) => Some("passive"),
158        Some(PathPart::Part("enablePrimaryFireFreezeStack")) => Some("primaryFire"),
159        Some(PathPart::Part(key)) if key.starts_with("ability1") => Some("ability1"),
160        Some(PathPart::Part(key)) if key.starts_with("ability2") => Some("ability2"),
161        Some(PathPart::Part(key)) if key.starts_with("ability3") => Some("ability3"),
162        Some(PathPart::Part(key)) if key.starts_with("secondaryFire") => Some("secondaryFire"),
163        _ => table::ability_slot_for_path(path),
164    }
165}
166
167fn reconciled_enum_domain(domain: &'static str) -> &'static str {
168    let mut targets = reconciliation::data()
169        .enum_member_mappings
170        .iter()
171        .filter(|mapping| mapping.source_domain == domain)
172        .map(|mapping| mapping.target_domain.as_str());
173    let Some(first) = targets.next() else {
174        return domain;
175    };
176    if targets.all(|target| target == first) {
177        first
178    } else {
179        domain
180    }
181}
182
183fn domain_for(kind: KeyKind) -> SettingValueDomain {
184    match kind {
185        KeyKind::Flag => SettingValueDomain::PresenceOnly,
186        KeyKind::String => SettingValueDomain::String,
187        KeyKind::Bool | KeyKind::YesNo | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean,
188        KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()),
189        KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()),
190        KeyKind::Enum(domain) => SettingValueDomain::Enum {
191            domain: domain.to_string(),
192        },
193        KeyKind::ListMap => SettingValueDomain::MapList,
194        KeyKind::ListHero => SettingValueDomain::HeroList,
195    }
196}
197
198fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option<SettingId> {
199    let prefix = match scope {
200        SettingScope::Main => "main",
201        SettingScope::Lobby => "lobby",
202        SettingScope::GameModes => "gameMode",
203        SettingScope::Heroes => "hero",
204        SettingScope::Extensions => "extension",
205        SettingScope::Workshop => "workshop",
206        SettingScope::Unknown => "unknown",
207    };
208    if matches!(scope, SettingScope::Unknown) {
209        return None;
210    }
211    let concept = canonical_concept(key, path)?;
212    Some(SettingId::new(format!("setting.{prefix}.{concept}")))
213}
214
215fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option<String> {
216    let key = key.trim_end_matches('%');
217    Some(match key {
218        "health" => "health".to_string(),
219        "damageDealt" | "damageReceived" | "healingDealt" | "healingReceived" => key.to_string(),
220        "passiveUltGen" => "ultimateGeneration.passive".to_string(),
221        "combatUltGen" => "ultimateGeneration.combat".to_string(),
222        "ultGen" => "ultimateGeneration".to_string(),
223        "enableUlt" => "ability.enabled".to_string(),
224        "enablePrimaryFire"
225        | "enableSecondaryFire"
226        | "enableGenericSecondaryFire"
227        | "enableAbility1"
228        | "enableAbility2"
229        | "enableAbility3" => "ability.enabled".to_string(),
230        "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(),
231        "enableScoping" => "primaryFire.scopingEnabled".to_string(),
232        "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(),
233        "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(),
234        "setValidControlPoints" | "firstActiveControlPoint" => path
235            .iter()
236            .filter_map(|part| match part {
237                PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name),
238                _ => None,
239            })
240            .next()
241            .map(|mode| format!("{key}.{mode}"))?,
242        _ => key.to_string(),
243    })
244}
245
246/// Validate the effective settings catalog and reject stale or conflicting
247/// semantic projections before parser/emitter data is shipped.
248pub fn validate_catalog() -> Result<(), Vec<String>> {
249    use std::collections::{HashMap, HashSet};
250
251    let mut errors = Vec::new();
252    errors.extend(reconciliation::validate());
253    errors.extend(validate_raw_projection(table::raw_entries()));
254    errors.extend(validate_enum_projection(
255        table::ENUM_MEMBERS.iter(),
256        table::GENERATED_ENUM_MEMBERS.iter(),
257        &reconciliation::data().enum_member_mappings,
258    ));
259    let mut paths = HashSet::new();
260    let mut concepts: HashMap<(String, SettingTargetKind, String), SettingValueDomain> =
261        HashMap::new();
262    let mut concept_keys: HashMap<(String, SettingTargetKind), String> = HashMap::new();
263
264    for definition in definitions() {
265        if !paths.insert(definition.path.clone()) {
266            errors.push(format!("duplicate settings path: {}", definition.path));
267        }
268        if definition.scope == SettingScope::Unknown {
269            errors.push(format!("unknown settings scope: {}", definition.path));
270        }
271        let Some(id) = definition.id() else {
272            errors.push(format!(
273                "missing canonical settings identity: {}",
274                definition.path
275            ));
276            continue;
277        };
278        if !definition.source.reviewed {
279            errors.push(format!(
280                "unreviewed settings definition: {}",
281                definition.path
282            ));
283        }
284        if definition.presentation.english_name.is_empty() {
285            errors.push(format!(
286                "missing settings presentation: {}",
287                definition.path
288            ));
289        }
290        let target_kind = definition.target_kind();
291        let semantic_key = semantic_identity_key(definition.key);
292        let collision_key = (id.as_str().to_string(), target_kind.clone());
293        if let Some(previous_key) = concept_keys.insert(collision_key, semantic_key.clone()) {
294            if previous_key != semantic_key {
295                errors.push(format!(
296                    "conflicting settings concepts for {id}: {previous_key} vs {semantic_key}"
297                ));
298            }
299        }
300        let key = (id.as_str().to_string(), target_kind, semantic_key);
301        if let Some(previous) = concepts.insert(key, definition.domain.clone()) {
302            if previous != definition.domain {
303                errors.push(format!("conflicting settings domains for {id}"));
304            }
305        }
306    }
307    if errors.is_empty() {
308        Ok(())
309    } else {
310        Err(errors)
311    }
312}
313
314pub(super) fn validate_raw_projection(
315    entries: impl IntoIterator<Item = table::ProjectedEntry>,
316) -> Vec<String> {
317    use std::collections::HashMap;
318
319    let mut errors = Vec::new();
320    let mut paths = HashMap::new();
321    for projected in entries {
322        let entry = projected.entry;
323        if let Some(previous) = paths.insert(entry.path, projected) {
324            if previous.entry != entry
325                && !reconciled_entry_override(
326                    table::path_string(entry.path).as_str(),
327                    previous,
328                    projected,
329                )
330            {
331                errors.push(format!(
332                    "conflicting duplicate settings path between {} and {}: {}",
333                    previous.source.label(),
334                    projected.source.label(),
335                    table::path_string(entry.path),
336                ));
337            }
338        }
339    }
340    errors
341}
342
343fn reconciled_entry_override(
344    path: &str,
345    fixture: table::ProjectedEntry,
346    generated: table::ProjectedEntry,
347) -> bool {
348    use table::ProjectionSource::{FixtureTable, WorkshopDataExport};
349
350    let (fixture, generated) = match (fixture.source, generated.source) {
351        (FixtureTable, WorkshopDataExport) => (fixture.entry, generated.entry),
352        (WorkshopDataExport, FixtureTable) => (generated.entry, fixture.entry),
353        _ => return false,
354    };
355    reconciliation::data()
356        .entry_overrides
357        .iter()
358        .find(|override_| override_.path == path)
359        .is_some_and(|override_| {
360            entry_contract_matches(fixture, &override_.fixture)
361                && entry_contract_matches(generated, &override_.generated)
362        })
363}
364
365fn entry_contract_matches(entry: &TableEntry, expected: &reconciliation::EntryContract) -> bool {
366    entry.workshop_name == expected.name && key_kind_matches(entry.kind, expected)
367}
368
369fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> bool {
370    match (kind, expected.kind.as_str(), expected.domain.as_deref()) {
371        (KeyKind::Flag, "flag", None)
372        | (KeyKind::String, "string", None)
373        | (KeyKind::Bool, "bool", None)
374        | (KeyKind::YesNo, "yesNo", None)
375        | (KeyKind::Number, "number", None)
376        | (KeyKind::Percent, "percent", None)
377        | (KeyKind::ListMap, "mapList", None)
378        | (KeyKind::ListHero, "heroList", None) => true,
379        (KeyKind::BoolEnum(actual), "boolEnum", Some(expected)) => actual == expected,
380        (KeyKind::Enum(actual), "enum", Some(expected)) => actual == expected,
381        _ => false,
382    }
383}
384
385pub(super) fn validate_enum_projection(
386    fixture_entries: impl IntoIterator<Item = &'static table::EnumMember>,
387    generated_entries: impl IntoIterator<Item = &'static table::EnumMember>,
388    mappings: &[reconciliation::EnumMemberMapping],
389) -> Vec<String> {
390    use std::collections::{HashMap, HashSet};
391
392    let domains: HashSet<_> = table::entries()
393        .filter_map(|entry| match entry.kind {
394            KeyKind::Enum(domain) | KeyKind::BoolEnum(domain) => Some(domain),
395            _ => None,
396        })
397        .collect();
398    let mut errors = Vec::new();
399    let mut members = HashMap::new();
400    let mut names = HashMap::new();
401    for member in fixture_entries {
402        if !domains.contains(member.domain) {
403            errors.push(format!("orphaned settings enum domain: {}", member.domain));
404        }
405        let key = (member.domain, member.member);
406        if let Some(previous) = members.insert(key, member.name) {
407            if previous != member.name {
408                errors.push(format!(
409                    "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
410                    member.domain, member.member, member.name
411                ));
412            }
413        }
414        if let Some(previous) = names.insert((member.domain, member.name), member.member) {
415            if previous != member.member {
416                errors.push(format!(
417                    "conflicting settings enum display name {}.{:?}: {previous} vs {}",
418                    member.domain, member.name, member.member
419                ));
420            }
421        }
422    }
423    let fixture_members: HashMap<_, _> = table::ENUM_MEMBERS
424        .iter()
425        .map(|member| ((member.domain, member.member), member))
426        .collect();
427    let mut mapped_sources = HashSet::new();
428    for member in generated_entries {
429        let key = (member.domain, member.member);
430        if let Some(previous) = members.insert(key, member.name) {
431            if previous != member.name {
432                errors.push(format!(
433                    "conflicting settings enum member {}.{}: {previous:?} vs {:?}",
434                    member.domain, member.member, member.name
435                ));
436            }
437        }
438        let mapping = mappings.iter().find(|mapping| {
439            mapping.source_domain == member.domain && mapping.source_member == member.member
440        });
441        if mapping.is_none() && !domains.contains(member.domain) {
442            errors.push(format!("orphaned settings enum domain: {}", member.domain));
443        }
444        let (domain, canonical_member, name) = match mapping {
445            Some(mapping) => {
446                if !mapped_sources.insert((
447                    mapping.source_domain.as_str(),
448                    mapping.source_member.as_str(),
449                )) {
450                    errors.push(format!(
451                        "duplicate settings enum reconciliation for {}.{}",
452                        mapping.source_domain, mapping.source_member
453                    ));
454                }
455                match fixture_members.get(&(
456                    mapping.target_domain.as_str(),
457                    mapping.target_member.as_str(),
458                )) {
459                    Some(target) if target.name == member.name => {
460                        (target.domain, target.member, target.name)
461                    }
462                    Some(target) => {
463                        errors.push(format!(
464                            "settings enum reconciliation name mismatch {}.{} -> {}.{}: {:?} vs {:?}",
465                            mapping.source_domain, mapping.source_member,
466                            mapping.target_domain, mapping.target_member, member.name, target.name
467                        ));
468                        continue;
469                    }
470                    None => {
471                        errors.push(format!(
472                            "settings enum reconciliation target is missing: {}.{} -> {}.{}",
473                            mapping.source_domain,
474                            mapping.source_member,
475                            mapping.target_domain,
476                            mapping.target_member
477                        ));
478                        continue;
479                    }
480                }
481            }
482            None => (member.domain, member.member, member.name),
483        };
484        if let Some(previous) = names.insert((domain, name), canonical_member) {
485            if previous != canonical_member {
486                errors.push(format!(
487                    "conflicting settings enum display name {}.{name:?}: {previous} vs {canonical_member}",
488                    domain
489                ));
490            }
491        }
492    }
493    for mapping in mappings {
494        if !mapped_sources.contains(&(
495            mapping.source_domain.as_str(),
496            mapping.source_member.as_str(),
497        )) {
498            errors.push(format!(
499                "orphaned settings enum reconciliation: {}.{}",
500                mapping.source_domain, mapping.source_member
501            ));
502        }
503    }
504    errors
505}
506
507fn semantic_identity_key(key: &str) -> String {
508    match key {
509        "enableSecondaryFire" | "enableGenericSecondaryFire" => "enableSecondaryFire".to_string(),
510        _ => key.to_string(),
511    }
512}