diff --git a/crates/workshop-rs/src/bin/workshop-catalog-gen.rs b/crates/workshop-rs/src/bin/workshop-catalog-gen.rs index 54237e8..a95cbdb 100644 --- a/crates/workshop-rs/src/bin/workshop-catalog-gen.rs +++ b/crates/workshop-rs/src/bin/workshop-catalog-gen.rs @@ -41,7 +41,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use workshop_rs::catalog::{Catalog, build_canonical}; -use workshop_rs::settings::table; +use workshop_rs::settings::{schema, table}; /// The committed catalog data, relative to the workspace root (where CI and /// the documented pipeline commands run); `--file` overrides it. @@ -121,6 +121,12 @@ fn main() -> ExitCode { match command.as_deref() { Some("check") => match Catalog::load(&content) { Ok(catalog) => { + if let Err(errors) = schema::validate_catalog() { + for error in errors { + eprintln!("workshop-catalog-gen: settings catalog: {error}"); + } + return ExitCode::from(1); + } let identity = catalog.identity(); if json { match serde_json::to_string_pretty(&identity) { diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index be202d5..78ecdfc 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -95,7 +95,7 @@ pub enum SettingTarget { /// The target shape described by a definition. Concrete identities are /// supplied separately when applicability is queried. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum SettingTargetKind { Global, Mode, @@ -521,7 +521,9 @@ fn hero_ability_exists( } /// Project all currently reviewed table entries into the canonical semantic -/// model. This is intentionally a projection, not a second settings catalog. +/// catalog. The table remains the single parser/emitter source; this +/// projection supplies the stable semantic identity and typed facts consumed +/// by callers. pub fn definitions() -> impl Iterator { table::entries().map(SettingDefinition::from_entry) } @@ -679,15 +681,104 @@ fn canonical_id(scope: SettingScope, key: &str, path: &[PathPart<'_>]) -> Option SettingScope::Workshop => "workshop", SettingScope::Unknown => "unknown", }; - if matches!(scope, SettingScope::Unknown) - || matches!(scope, SettingScope::Heroes) && semantic_ability_slot_for_path(path).is_some() - { + if matches!(scope, SettingScope::Unknown) { return None; } - Some(SettingId::new(format!( - "setting.{prefix}.{}", - key.trim_end_matches('%') - ))) + let concept = canonical_concept(key, path)?; + Some(SettingId::new(format!("setting.{prefix}.{concept}"))) +} + +/// Map a Workshop leaf to a locale-independent setting concept. These names +/// intentionally describe the setting's meaning, while hero and logical slot +/// topology stays in `SettingTarget`. +fn canonical_concept(key: &str, path: &[PathPart<'_>]) -> Option { + let key = key.trim_end_matches('%'); + Some(match key { + "health" => "health".to_string(), + "passiveUltGen" => "ultimateGeneration.passive".to_string(), + "combatUltGen" => "ultimateGeneration.combat".to_string(), + "ultGen" => "ultimateGeneration".to_string(), + "enableUlt" => "ability.enabled".to_string(), + "enablePrimaryFire" + | "enableSecondaryFire" + | "enableGenericSecondaryFire" + | "enableAbility1" + | "enableAbility2" + | "enableAbility3" => "ability.enabled".to_string(), + "enableAutomaticFire" => "primaryFire.automaticFireEnabled".to_string(), + "enableScoping" => "primaryFire.scopingEnabled".to_string(), + "enablePassiveUnlimitedFuel" => "passive.unlimitedFuelEnabled".to_string(), + "enablePrimaryFireFreezeStack" => "primaryFire.freezeStackEnabled".to_string(), + key if key.ends_with("Cooldown") => "ability.cooldown".to_string(), + key if key.ends_with("RechargeRate") => "ability.rechargeRate".to_string(), + key if key.ends_with("EnergyChargeRate") => "ability.energyChargeRate".to_string(), + key if key.ends_with("MaximumTime") || key.ends_with("MaxTime") => { + "ability.maximumTime".to_string() + } + key if key.contains("EnemyKb") => "ability.knockback.enemy".to_string(), + key if key.contains("Kb") => "ability.knockback".to_string(), + key if key.contains("Damage") => "ability.damage".to_string(), + key if key.contains("Healing") => "ability.healing".to_string(), + key if key.contains("Resource") => "ability.resource".to_string(), + "setValidControlPoints" | "firstActiveControlPoint" => path + .iter() + .filter_map(|part| match part { + PathPart::Part(name) if *name != "gamemodes" && *name != key => Some(*name), + _ => None, + }) + .next() + .map(|mode| format!("{key}.{mode}"))?, + _ => key.to_string(), + }) +} + +/// Validate the effective settings catalog and reject stale or conflicting +/// semantic projections before parser/emitter data is shipped. +pub fn validate_catalog() -> Result<(), Vec> { + use std::collections::{HashMap, HashSet}; + + let mut errors = Vec::new(); + let mut paths = HashSet::new(); + let mut concepts: HashMap<(String, SettingTargetKind), SettingValueDomain> = HashMap::new(); + + for definition in definitions() { + if !paths.insert(definition.path.clone()) { + errors.push(format!("duplicate settings path: {}", definition.path)); + } + if definition.scope == SettingScope::Unknown { + errors.push(format!("unknown settings scope: {}", definition.path)); + } + let Some(id) = definition.id() else { + errors.push(format!( + "missing canonical settings identity: {}", + definition.path + )); + continue; + }; + if !definition.provenance.reviewed { + errors.push(format!( + "unreviewed settings definition: {}", + definition.path + )); + } + if definition.presentation.english_name.is_empty() { + errors.push(format!( + "missing settings presentation: {}", + definition.path + )); + } + let key = (id.as_str().to_string(), definition.target_kind()); + if let Some(previous) = concepts.insert(key, definition.domain.clone()) { + if previous != definition.domain { + errors.push(format!("conflicting settings domains for {id}")); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } } #[cfg(test)] diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index cb21ca0..04740b0 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -956,9 +956,16 @@ pub fn lookup(path: &[PathPart<'_>]) -> Option<&'static TableEntry> { }) } -/// Iterate the reviewed hand-written and generated settings inventory. +/// Iterate the reviewed settings inventory with generated export entries +/// taking precedence over the legacy hand-written projection. Duplicate +/// paths are therefore represented once in the semantic catalog while the +/// parser and emitter continue to use the same lookup table. pub fn entries() -> impl Iterator { - ENTRIES.iter().chain(GENERATED_ENTRIES.iter()) + let mut paths = std::collections::HashSet::new(); + ENTRIES + .iter() + .chain(GENERATED_ENTRIES.iter()) + .filter(move |entry| paths.insert(path_string(entry.path))) } pub(crate) fn is_generated_entry(entry: &TableEntry) -> bool { diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index 038d4bb..247992d 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use workshop_rs::catalog::{Catalog, Locale}; use workshop_rs::gameplay::{AbilityVariant, HeroId, LogicalSlot, hero_ids, slots}; use workshop_rs::settings::{ - Applicability, NumericBounds, SettingEvidenceKind, SettingIdentity, SettingScope, + Applicability, NumericBounds, SettingEvidenceKind, SettingId, SettingIdentity, SettingScope, SettingTarget, SettingTargetKind, SettingValueDomain, TeamId, definitions, }; use workshop_rs::{convert, emitter, parser, roundtrip, semantic}; @@ -320,8 +320,11 @@ fn settings_schema_projects_workshop_facts_without_display_names_in_ids() { Ok(Some("燃火链式机枪")) ); assert!(hero_ability.provenance().reviewed); - assert!(hero_ability.id().is_none()); - assert!(matches!(hero_ability.identity(), SettingIdentity::Unknown)); + assert_eq!( + hero_ability.id().map(SettingId::as_str), + Some("setting.hero.ability.enabled") + ); + assert!(matches!(hero_ability.identity(), SettingIdentity::Known(_))); assert_eq!( hero_ability.target_kind(), SettingTargetKind::HeroAbility { @@ -530,7 +533,7 @@ fn settings_schema_normalizes_concept_ids_and_group_targets() { .iter() .find(|definition| definition.path().ends_with(suffix)) .expect("projected ability setting"); - assert!(definition.id().is_none()); + assert!(definition.id().is_some()); assert!(definition.provenance().reviewed); } let automatic_fire = definitions @@ -541,8 +544,14 @@ fn settings_schema_normalizes_concept_ids_and_group_targets() { .iter() .find(|definition| definition.path().ends_with("enableScoping")) .expect("scoping setting"); - assert!(automatic_fire.id().is_none()); - assert!(scoping.id().is_none()); + assert_eq!( + automatic_fire.id().map(SettingId::as_str), + Some("setting.hero.primaryFire.automaticFireEnabled") + ); + assert_eq!( + scoping.id().map(SettingId::as_str), + Some("setting.hero.primaryFire.scopingEnabled") + ); assert_ne!(automatic_fire.path(), scoping.path()); let general = definitions @@ -660,3 +669,8 @@ fn settings_schema_preserves_locale_and_evidence_provenance() { SettingEvidenceKind::WorkshopDataExport ); } + +#[test] +fn settings_schema_catalog_is_complete_and_conflict_checked() { + workshop_rs::settings::schema::validate_catalog().expect("reviewed settings catalog"); +} diff --git a/docs/adr/0006-settings-semantic-schema.md b/docs/adr/0006-settings-semantic-schema.md index 95ce412..35cb0ec 100644 --- a/docs/adr/0006-settings-semantic-schema.md +++ b/docs/adr/0006-settings-semantic-schema.md @@ -2,7 +2,7 @@ ## Status -Accepted for the #109 foundation; full catalog population remains #110 and +Accepted for the #109 foundation and #110 canonical catalog projection; ergonomic query/edit APIs remain #111. ## Decision @@ -56,9 +56,14 @@ semantic identity until #110 supplies the canonical typed catalog. ## Consequences The existing `TableEntry` inventory remains the parser/emitter source and the -schema is a single semantic projection of it, avoiding a parallel settings -framework. #110 can replace or extend the projection with generated catalog -data without changing the public semantic boundary. #111 can build query/edit +schema is its single typed semantic projection, avoiding a parallel settings +framework. The effective catalog preserves the established hand-authored +parser precedence while collapsing duplicate paths from the generated +projection. It validates unresolved scopes, missing identities, missing +presentation, and conflicting domains before catalog-check success. Canonical +concept identities normalize reusable hero/ability settings +without embedding localized ability display names; mode-specific enum concepts +remain distinct when their reviewed domains differ. #111 can build query/edit operations on definitions and source-preserving occurrences without inventing another identity, scope, target, domain, or provenance model. diff --git a/docs/language-support/settings.md b/docs/language-support/settings.md index 3001fac..62b4065 100644 --- a/docs/language-support/settings.md +++ b/docs/language-support/settings.md @@ -12,3 +12,32 @@ | `heroes` (Hero settings) | ✅ Supported | Global hero rules, roster toggles (`enabled heroes` / `disabled heroes`), role limits, and per-hero ability/weapon/cooldown parameters. | | `extensions` (Workshop extensions) | ✅ Supported | Extension flags (`Beam Effects`, `Buff Status Effects`, `Debuff Status Effects`, `Buff and Debuff Sounds`, `Energy Explosion Effects`, `Kinetic Explosion Effects`, `Play More Effects`, `Spawn More Dummy Bots`). | | `workshop` (Custom workshop settings) | ✅ Supported | User-defined custom settings defined via `Workshop Setting ...` values in rules. | + +## Canonical typed catalog + +The `workshop_rs::settings` module exposes the reviewed settings catalog +through `definitions()`. Each `SettingDefinition` carries a locale-independent +`SettingId`, Workshop `SettingScope`, target shape, typed +`SettingValueDomain`, locale presentation metadata, and evidence provenance. +The source-preserving `Settings` / `SettingsNode` tree remains the authored +value carrier; the catalog does not regenerate or discard unknown settings. + +```rust +use workshop_rs::settings::{definitions, SettingTarget, SettingValueDomain}; + +let health = definitions() + .find(|definition| definition.id().is_some_and(|id| id.as_str() == "setting.hero.health")) + .expect("canonical hero health setting"); +assert!(matches!(health.domain(), SettingValueDomain::Percent(_))); +assert!(health + .applicability(&SettingTarget::Hero { + team: None, + hero: "ana".into(), + }) + .is_ok()); +``` + +Hero and ability display names are presentation data only. Consumers use the +canonical concept and `SettingTarget`; localized aliases remain parser/emitter +resolution details. Numeric bounds are explicit when reviewed evidence proves +them, and otherwise remain unknown rather than being guessed.