Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/workshop-rs/src/bin/workshop-catalog-gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
109 changes: 100 additions & 9 deletions crates/workshop-rs/src/settings/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Item = SettingDefinition> {
table::entries().map(SettingDefinition::from_entry)
}
Expand Down Expand Up @@ -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<String> {
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<String>> {
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)]
Expand Down
11 changes: 9 additions & 2 deletions crates/workshop-rs/src/settings/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = &'static TableEntry> {
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 {
Expand Down
26 changes: 20 additions & 6 deletions crates/workshop-rs/tests/settings_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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");
}
13 changes: 9 additions & 4 deletions docs/adr/0006-settings-semantic-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
29 changes: 29 additions & 0 deletions docs/language-support/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading