diff --git a/crates/workshop-rs/src/settings/check.rs b/crates/workshop-rs/src/settings/check.rs index 8d1ed2bc..cd4956c6 100644 --- a/crates/workshop-rs/src/settings/check.rs +++ b/crates/workshop-rs/src/settings/check.rs @@ -290,6 +290,9 @@ fn walk_heroes<'a>(teams: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_ continue; } for inner in children { + if let Some(diagnostic) = unemittable_hero_member(inner, name) { + visit(Visit::Rejected(diagnostic)); + } visit(Visit::Member(inner, &HERO)); } } @@ -299,6 +302,50 @@ fn walk_heroes<'a>(teams: &'a [SettingsNode], visit: &mut dyn FnMut(Visit<'a, '_ } } +/// A hero-scoped member the emitter can never spell: the key derives from an +/// ability slot the hero provably lacks and no reviewed per-hero label row +/// exists to substitute, so emission fails with a missing-mapping error. +/// The rejection is reported here so `check` agrees with emission; a hero +/// absent from gameplay data is not provable and is left to the emit-time +/// failure the module header documents. +fn unemittable_hero_member(node: &SettingsNode, hero: &str) -> Option { + // `Raw` and `RawGroup` members emit verbatim and never resolve a + // display name. + if matches!( + node, + SettingsNode::Raw { .. } | SettingsNode::RawGroup { .. } + ) { + return None; + } + let key = node.name(); + let slot = table::ability_slot_for_path(&[ + PathPart::Part("heroes"), + PathPart::Team, + PathPart::Hero, + PathPart::Part(key), + ])?; + if table::hero_setting_name(hero, key, "en-US").is_some() { + return None; + } + hero_provably_lacks_slot(hero, slot).then(|| { + rejected( + node.span(), + format!("hero '{hero}' has no '{slot}' ability for settings key '{key}'"), + None, + ) + }) +} + +fn hero_provably_lacks_slot(hero: &str, slot: &str) -> bool { + crate::gameplay::data::builtin_ref() + .ok() + .and_then(|catalog| catalog.hero(&crate::gameplay::HeroId::new(hero))) + .is_some_and(|hero| { + hero.abilities_in_slot(&crate::gameplay::LogicalSlot::new(slot)) + .is_empty() + }) +} + /// Build the warning for a carried member close to one declared spelling: /// a misspelled key names the declared key, a misspelled value under a /// catalogued key names the declared enum member. diff --git a/crates/workshop-rs/tests/audit_regressions.rs b/crates/workshop-rs/tests/audit_regressions.rs index 9772b2d8..3a36b0e6 100644 --- a/crates/workshop-rs/tests/audit_regressions.rs +++ b/crates/workshop-rs/tests/audit_regressions.rs @@ -5,7 +5,8 @@ use super::common::{catalog, en}; use super::internal; use workshop_rs::parser; use workshop_rs::settings::{ - SettingId, SettingOperationError, SettingTarget, SettingValue, definitions, definitions_by_id, + DiagnosticSeverity, SettingId, SettingOperationError, SettingTarget, SettingValue, definitions, + definitions_by_id, }; /// W1: a leading UTF-8 BOM must not be a lex error. @@ -255,6 +256,33 @@ fn rowless_hero_composes_templated_labels() { internal::assert_emit_round_trip(&program); } +/// S5/S6 follow-up: a hero-scoped member whose slot the hero provably +/// lacks — and which no reviewed label row names — is rejected by `check` +/// with a diagnostic instead of reaching emission's missing-mapping +/// failure or a malformed `%1$s` spelling. The rejection keys on the +/// missing ability, not on the hero lacking generated rows: Doctrine's +/// `ability1Cooldown%` still emits through the gameplay-name template. +#[test] +fn hero_member_without_slot_is_rejected_by_check() { + let source = "settings\n{\n heroes\n {\n Team 1\n {\n Doctrine\n {\n enableAbility3: Off\n }\n }\n }\n}"; + let program = parser::parse(source, &catalog(), &en()).expect("source parses"); + let diagnostics = program.settings_diagnostics(); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.severity == DiagnosticSeverity::Error + && diagnostic.error.to_string().contains("ability3") + }), + "check must reject the slot-less member with a diagnostic, got {diagnostics:?}" + ); + assert!( + program.validate().is_err(), + "validation must fail on the unemittable member" + ); + internal::assert_emit_round_trip(&internal::parse( + "settings\n{\n heroes\n {\n Team 1\n {\n Doctrine\n {\n ability1Cooldown%: 50%\n }\n }\n }\n}", + )); +} + /// S8: under `wreckingBall` the zh spelling `工程抓钩冷却时间` is the /// generated `secondaryFireCooldown%` label — the stale alias must not bind /// it to `ability1Cooldown%` (whose zh label is `动力铁球冷却时间`).