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