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
47 changes: 47 additions & 0 deletions crates/workshop-rs/src/settings/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand All @@ -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<SettingsDiagnostic> {
// `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.
Expand Down
30 changes: 29 additions & 1 deletion crates/workshop-rs/tests/audit_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `动力铁球冷却时间`).
Expand Down
Loading