From e96a74e0ac6aca0da0dc286b79d6d6938ff1ea44 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Fri, 28 Aug 2026 16:21:16 +0800 Subject: [PATCH 1/2] feat(settings): add typed occurrence operations Refs #111 --- crates/workshop-rs/src/settings/mod.rs | 6 +- crates/workshop-rs/src/settings/schema.rs | 371 +++++++++++++++++- crates/workshop-rs/src/settings/table.rs | 42 ++ crates/workshop-rs/tests/settings_pipeline.rs | 101 ++++- docs/language-support/settings.md | 5 + 5 files changed, 511 insertions(+), 14 deletions(-) diff --git a/crates/workshop-rs/src/settings/mod.rs b/crates/workshop-rs/src/settings/mod.rs index 93680b2..5a39c0a 100644 --- a/crates/workshop-rs/src/settings/mod.rs +++ b/crates/workshop-rs/src/settings/mod.rs @@ -13,9 +13,9 @@ pub mod table; pub use schema::{ Applicability, EffectiveNumber, NumericBounds, NumericBoundsError, SettingDefinition, - SettingEvidenceKind, SettingId, SettingIdentity, SettingPresentation, SettingProvenance, - SettingScope, SettingTarget, SettingTargetKind, SettingValueDomain, TeamId, definition, - definitions, + SettingEvidenceKind, SettingId, SettingIdentity, SettingOccurrence, SettingOperationError, + SettingPresentation, SettingProvenance, SettingScope, SettingTarget, SettingTargetKind, + SettingValue, SettingValueDomain, TeamId, definition, definitions, }; use crate::source::Span; diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index 78ecdfc..e0dd039 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -10,6 +10,7 @@ use crate::gameplay::{AbilityVariant, HeroId, LogicalSlot}; use crate::{gameplay::GameplayDataError, gameplay_data}; use super::table::{self, KeyKind, PathPart, TableEntry}; +use super::{Settings, SettingsNode}; /// A locale-independent Workshop setting concept identity. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -205,6 +206,50 @@ pub enum SettingValueDomain { PresenceOnly, } +/// A typed authored value in the settings carrier. +#[derive(Debug, Clone, PartialEq)] +pub enum SettingValue { + Boolean(bool), + Number(f64), + Percent(f64), + String(String), + Enum(String), + HeroList(Vec), + MapList(Vec), + PresenceOnly, +} + +/// A typed occurrence together with an evidenced effective numeric value. +#[derive(Debug, Clone, PartialEq)] +pub struct SettingOccurrence { + pub authored: SettingValue, + pub effective: Option, +} + +/// Failure from a typed settings query or source-preserving edit. +#[derive(Debug, Clone, PartialEq)] +pub enum SettingOperationError { + NotApplicable { + setting: SettingId, + target: SettingTarget, + }, + NotFound { + setting: SettingId, + target: SettingTarget, + }, + WrongValueKind { + setting: SettingId, + expected: &'static str, + actual: &'static str, + span: Option, + }, + InvalidValue { + setting: SettingId, + message: String, + span: Option, + }, +} + impl SettingValueDomain { /// Apply evidenced effective clamping without changing the authored /// value held by [`super::SettingsNode`]. @@ -253,6 +298,7 @@ pub struct SettingDefinition { identity: SettingIdentity, scope: SettingScope, path: String, + path_parts: &'static [PathPart<'static>], key: &'static str, target: TargetPattern, domain: SettingValueDomain, @@ -357,7 +403,7 @@ impl SettingDefinition { } (TargetPattern::Team(expected), SettingTarget::Hero { team, .. }) => { if team_matches(expected.as_deref(), team.as_ref()) { - Applicability::Unknown + Applicability::Applicable } else { Applicability::NotApplicable } @@ -407,7 +453,7 @@ impl SettingDefinition { Applicability::NotApplicable } else { match hero_ability_exists(actual_hero, actual_slot, actual_variant.as_ref())? { - Some(true) => Applicability::Unknown, + Some(true) => Applicability::Applicable, Some(false) => Applicability::NotApplicable, None => Applicability::Unknown, } @@ -458,7 +504,13 @@ impl SettingDefinition { match hero_ability_exists(actual_hero, actual_slot, target_variant(target))? { None => Applicability::Unknown, Some(false) => Applicability::NotApplicable, - Some(true) => Applicability::Unknown, + Some(true) => { + match table::hero_setting_applicability(actual_hero.as_str(), self.key) { + Some(true) => Applicability::Applicable, + Some(false) => Applicability::NotApplicable, + None => Applicability::Unknown, + } + } } } (TargetPattern::Unknown, _) => Applicability::Unknown, @@ -469,6 +521,317 @@ impl SettingDefinition { pub fn effective_number(&self, authored: f64) -> Option { self.domain.effective_number(authored) } + + /// Read an existing source-preserving occurrence with its authored value + /// and, when evidenced, its effective numeric value. + pub fn read( + &self, + settings: &Settings, + target: &SettingTarget, + ) -> Result { + let id = self.operation_id()?; + self.ensure_target(target)?; + let path = self.concrete_path(target); + let node = find_node(&settings.children, &path).ok_or_else(|| { + SettingOperationError::NotFound { + setting: id.clone(), + target: target.clone(), + } + })?; + let authored = value_from_node(node, &self.domain, &id)?; + let effective = match authored { + SettingValue::Number(value) | SettingValue::Percent(value) => { + self.effective_number(value) + } + _ => None, + }; + Ok(SettingOccurrence { + authored, + effective, + }) + } + + /// Update one existing occurrence without rebuilding the surrounding + /// settings tree. Unknown and unrelated source structure is untouched. + pub fn write( + &self, + settings: &mut Settings, + target: &SettingTarget, + value: SettingValue, + ) -> Result<(), SettingOperationError> { + let id = self.operation_id()?; + self.ensure_target(target)?; + validate_value(&self.domain, &id, &value, None)?; + let path = self.concrete_path(target); + let node = find_node_mut(&mut settings.children, &path).ok_or_else(|| { + SettingOperationError::NotFound { + setting: id.clone(), + target: target.clone(), + } + })?; + apply_value(node, &id, value) + } + + fn ensure_target(&self, target: &SettingTarget) -> Result<(), SettingOperationError> { + let id = self.operation_id()?; + match self + .applicability(target) + .map_err(|error| SettingOperationError::InvalidValue { + setting: id.clone(), + message: error.to_string(), + span: None, + })? { + Applicability::NotApplicable => Err(SettingOperationError::NotApplicable { + setting: id, + target: target.clone(), + }), + Applicability::Applicable | Applicability::Unknown => Ok(()), + } + } + + fn operation_id(&self) -> Result { + self.id() + .cloned() + .ok_or_else(|| SettingOperationError::InvalidValue { + setting: SettingId::new("unknown"), + message: "setting has no reviewed canonical identity".to_string(), + span: None, + }) + } + + fn concrete_path(&self, target: &SettingTarget) -> Vec { + self.path_parts + .iter() + .map(|part| match part { + PathPart::Part(name) => (*name).to_string(), + PathPart::Team => target_team(target), + PathPart::Hero => target_hero(target), + }) + .collect() + } +} + +fn target_team(target: &SettingTarget) -> String { + match target { + SettingTarget::Team(team) + | SettingTarget::Hero { + team: Some(team), .. + } + | SettingTarget::TeamAbility { + team: Some(team), .. + } + | SettingTarget::HeroAbility { + team: Some(team), .. + } => team.as_str().to_string(), + _ => "allTeams".to_string(), + } +} + +fn target_hero(target: &SettingTarget) -> String { + match target { + SettingTarget::Hero { hero, .. } | SettingTarget::HeroAbility { hero, .. } => { + hero.as_str().to_string() + } + _ => String::new(), + } +} + +fn find_node<'a>(children: &'a [SettingsNode], path: &[String]) -> Option<&'a SettingsNode> { + let (name, rest) = path.split_first()?; + let node = children.iter().find(|node| node.name() == name)?; + if rest.is_empty() { + Some(node) + } else { + match node { + SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => { + find_node(children, rest) + } + _ => None, + } + } +} + +fn find_node_mut<'a>( + children: &'a mut [SettingsNode], + path: &[String], +) -> Option<&'a mut SettingsNode> { + let (name, rest) = path.split_first()?; + let node = children.iter_mut().find(|node| node.name() == name)?; + if rest.is_empty() { + Some(node) + } else { + match node { + SettingsNode::Workshop { children, .. } | SettingsNode::Group { children, .. } => { + find_node_mut(children, rest) + } + _ => None, + } + } +} + +fn value_kind(value: &SettingValue) -> &'static str { + match value { + SettingValue::Boolean(_) => "boolean", + SettingValue::Number(_) => "number", + SettingValue::Percent(_) => "percent", + SettingValue::String(_) => "string", + SettingValue::Enum(_) => "enum", + SettingValue::HeroList(_) => "hero-list", + SettingValue::MapList(_) => "map-list", + SettingValue::PresenceOnly => "presence-only", + } +} + +fn domain_kind(domain: &SettingValueDomain) -> &'static str { + match domain { + SettingValueDomain::Boolean => "boolean", + SettingValueDomain::Number(_) => "number", + SettingValueDomain::Percent(_) => "percent", + SettingValueDomain::String => "string", + SettingValueDomain::Enum { .. } => "enum", + SettingValueDomain::HeroList => "hero-list", + SettingValueDomain::MapList => "map-list", + SettingValueDomain::PresenceOnly => "presence-only", + } +} + +fn validate_value( + domain: &SettingValueDomain, + id: &SettingId, + value: &SettingValue, + span: Option, +) -> Result<(), SettingOperationError> { + let expected = domain_kind(domain); + if value_kind(value) != expected { + return Err(SettingOperationError::WrongValueKind { + setting: id.clone(), + expected, + actual: value_kind(value), + span, + }); + } + match (domain, value) { + ( + SettingValueDomain::Number(_) | SettingValueDomain::Percent(_), + SettingValue::Number(value) | SettingValue::Percent(value), + ) if !value.is_finite() => Err(SettingOperationError::InvalidValue { + setting: id.clone(), + message: "numeric settings values must be finite".to_string(), + span, + }), + (SettingValueDomain::Enum { domain }, SettingValue::Enum(member)) + if table::enum_name(domain, member).is_none() => + { + Err(SettingOperationError::InvalidValue { + setting: id.clone(), + message: format!("unknown member '{member}' for enum domain '{domain}'"), + span, + }) + } + (SettingValueDomain::HeroList, SettingValue::HeroList(values)) + if values.iter().any(|value| table::hero_name(value).is_none()) => + { + Err(SettingOperationError::InvalidValue { + setting: id.clone(), + message: "hero list contains an unknown hero".to_string(), + span, + }) + } + (SettingValueDomain::MapList, SettingValue::MapList(values)) + if values.iter().any(|value| table::map_name(value).is_none()) => + { + Err(SettingOperationError::InvalidValue { + setting: id.clone(), + message: "map list contains an unknown map".to_string(), + span, + }) + } + _ => Ok(()), + } +} + +fn value_from_node( + node: &SettingsNode, + domain: &SettingValueDomain, + id: &SettingId, +) -> Result { + let value = match node { + SettingsNode::Bool { value, .. } => SettingValue::Boolean(*value), + SettingsNode::Number { value, .. } => match domain { + SettingValueDomain::Percent(_) => SettingValue::Percent(*value), + _ => SettingValue::Number(*value), + }, + SettingsNode::String { value, .. } => match domain { + SettingValueDomain::Enum { .. } => SettingValue::Enum(value.clone()), + _ => SettingValue::String(value.clone()), + }, + SettingsNode::Flag { .. } => SettingValue::PresenceOnly, + SettingsNode::List { elements, .. } => { + let values = elements + .iter() + .map(|element| element.value.clone()) + .collect(); + match domain { + SettingValueDomain::HeroList => SettingValue::HeroList(values), + _ => SettingValue::MapList(values), + } + } + _ => { + return Err(SettingOperationError::InvalidValue { + setting: id.clone(), + message: "settings occurrence is not a typed leaf".to_string(), + span: node.span(), + }); + } + }; + validate_value(domain, id, &value, node.span())?; + Ok(value) +} + +fn apply_value( + node: &mut SettingsNode, + id: &SettingId, + value: SettingValue, +) -> Result<(), SettingOperationError> { + match (node, value) { + (SettingsNode::Bool { value: current, .. }, SettingValue::Boolean(value)) => { + *current = value + } + ( + SettingsNode::Number { value: current, .. }, + SettingValue::Number(value) | SettingValue::Percent(value), + ) => *current = value, + ( + SettingsNode::String { value: current, .. }, + SettingValue::String(value) | SettingValue::Enum(value), + ) => *current = value, + ( + SettingsNode::List { elements, span, .. }, + SettingValue::HeroList(values) | SettingValue::MapList(values), + ) => { + if elements.len() != values.len() { + return Err(SettingOperationError::InvalidValue { + setting: id.clone(), + message: "source-preserving list edits cannot change list length".to_string(), + span: *span, + }); + } + elements + .iter_mut() + .zip(values) + .for_each(|(element, value)| element.value = value); + } + (SettingsNode::Flag { .. }, SettingValue::PresenceOnly) => {} + (node, value) => { + return Err(SettingOperationError::WrongValueKind { + setting: id.clone(), + expected: "existing typed value", + actual: value_kind(&value), + span: node.span(), + }); + } + } + Ok(()) } #[derive(Debug, Clone, PartialEq)] @@ -554,6 +917,7 @@ impl SettingDefinition { identity, scope, path, + path_parts: entry.path, key, target, domain, @@ -790,6 +1154,7 @@ mod tests { identity: SettingIdentity::Known(SettingId::new("setting.test.value")), scope: SettingScope::Heroes, path: "heroes.test.value".to_string(), + path_parts: &[], key: "value", target, domain: SettingValueDomain::Boolean, diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index 04740b0..e74d693 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -1034,6 +1034,48 @@ pub fn hero_setting_name(hero: &str, key: &str, locale: &str) -> Option<&'static }) } +/// Return explicit applicability evidence for a hero setting from the +/// reviewed hero-setting export. `None` means the hero is not in the reviewed +/// roster; otherwise the exported presence/absence is the effective setting +/// applicability for this catalog surface. +pub fn hero_setting_applicability(hero: &str, key: &str) -> Option { + hero_name(hero)?; + // These common controls are represented by the Workshop hero settings + // table for every topology-valid hero, while the export only carries + // localized labels for a subset. Do not turn that presentation gap into a + // false negative for typed queries. + if matches!( + key, + "health%" + | "enablePrimaryFire" + | "enableSecondaryFire" + | "enableAbility1" + | "enableAbility2" + | "enableAbility3" + | "combatUltGen%" + | "passiveUltGen%" + ) { + return None; + } + let evidenced = GENERATED_HERO_SETTING_NAMES.iter().any(|entry| { + entry.key == key + && entry + .locales + .iter() + .any(|(_, value)| !value.trim().is_empty()) + }); + evidenced.then(|| { + GENERATED_HERO_SETTING_NAMES.iter().any(|entry| { + entry.hero == hero + && entry.key == key + && entry + .locales + .iter() + .any(|(_, value)| !value.trim().is_empty()) + }) + }) +} + #[derive(Deserialize)] struct HeroSettingAlias { hero: String, diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index 247992d..0ce932c 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -6,8 +6,9 @@ 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, SettingId, SettingIdentity, SettingScope, - SettingTarget, SettingTargetKind, SettingValueDomain, TeamId, definitions, + Applicability, NumericBounds, SettingEvidenceKind, SettingId, SettingIdentity, + SettingOperationError, SettingScope, SettingTarget, SettingTargetKind, SettingValue, + SettingValueDomain, TeamId, definitions, }; use workshop_rs::{convert, emitter, parser, roundtrip, semantic}; @@ -422,7 +423,7 @@ fn settings_schema_distinguishes_applicability_and_unknown_hero_evidence() { ashe_only .applicability(&ana_ability1) .expect("applicability"), - Applicability::Unknown + Applicability::NotApplicable ); let ability1 = definitions @@ -453,7 +454,7 @@ fn settings_schema_distinguishes_applicability_and_unknown_hero_evidence() { definition.path().ends_with("enablePrimaryFire") && matches!( definition.target_kind(), - SettingTargetKind::HeroAbility { .. } + SettingTargetKind::TeamAbility { .. } ) }) .expect("generic primary-fire setting"); @@ -466,7 +467,7 @@ fn settings_schema_distinguishes_applicability_and_unknown_hero_evidence() { variant: None, }) .expect("applicability"), - Applicability::Unknown + Applicability::Applicable ); let health = definitions @@ -602,7 +603,7 @@ fn settings_schema_normalizes_concept_ids_and_group_targets() { variant: Some(AbilityVariant::new("mech")), }) .expect("applicability"), - Applicability::Unknown + Applicability::Applicable ); assert_eq!( team_primary @@ -613,7 +614,7 @@ fn settings_schema_normalizes_concept_ids_and_group_targets() { variant: Some(AbilityVariant::new("pilot")), }) .expect("applicability"), - Applicability::Unknown + Applicability::Applicable ); assert_eq!( team_primary @@ -640,7 +641,7 @@ fn settings_schema_normalizes_concept_ids_and_group_targets() { hero: HeroId::from(hero_ids::ANA), }) .expect("applicability"), - Applicability::Unknown + Applicability::Applicable ); } @@ -674,3 +675,87 @@ fn settings_schema_preserves_locale_and_evidence_provenance() { fn settings_schema_catalog_is_complete_and_conflict_checked() { workshop_rs::settings::schema::validate_catalog().expect("reviewed settings catalog"); } + +#[test] +fn typed_settings_read_and_write_preserve_unrelated_structure() { + let catalog = Catalog::builtin().expect("catalog"); + let source = r#"settings { + main { + Description: "keep this" + } + lobby { + Max Spectators: 2 + } + heroes { + General { + D.Va { + Primary Fire: On + } + } + } + }"#; + let mut program = parser::parse(source, &catalog, &Locale::new("en-US")).expect("parse"); + let lobby = definitions() + .find(|definition| definition.path() == "lobby.spectatorSlots") + .expect("lobby definition"); + let read = lobby + .read( + program.settings.as_ref().expect("settings"), + &SettingTarget::Global, + ) + .expect("typed read"); + assert_eq!(read.authored, SettingValue::Number(2.0)); + assert_eq!(read.effective, None); + lobby + .write( + program.settings.as_mut().expect("settings"), + &SettingTarget::Global, + SettingValue::Number(4.0), + ) + .expect("typed write"); + let emitted = emitter::emit(&program, &catalog, &Locale::new("en-US")).expect("emit"); + assert!(emitted.contains("Description: \"keep this\"")); + assert!(emitted.contains("Max Spectators: 4")); + + let primary = definitions() + .find(|definition| { + definition.path().ends_with("enablePrimaryFire") + && matches!( + definition.target_kind(), + SettingTargetKind::HeroAbility { .. } + ) + }) + .expect("primary-fire definition"); + let target = SettingTarget::HeroAbility { + team: Some(TeamId::new("allTeams")), + hero: HeroId::from(hero_ids::DVA), + slot: LogicalSlot::from(slots::PRIMARY_FIRE), + variant: None, + }; + primary + .write( + program.settings.as_mut().expect("settings"), + &target, + SettingValue::Boolean(false), + ) + .expect("hero typed write"); + assert!(matches!( + primary + .read(program.settings.as_ref().expect("settings"), &target) + .expect("hero typed read") + .authored, + SettingValue::Boolean(false) + )); + + let error = primary + .write( + program.settings.as_mut().expect("settings"), + &target, + SettingValue::Number(1.0), + ) + .expect_err("wrong kind must be rejected"); + assert!(matches!( + error, + SettingOperationError::WrongValueKind { .. } + )); +} diff --git a/docs/language-support/settings.md b/docs/language-support/settings.md index 62b4065..5fd26c8 100644 --- a/docs/language-support/settings.md +++ b/docs/language-support/settings.md @@ -41,3 +41,8 @@ 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. + +`SettingDefinition::read` and `write` operate on existing occurrences. A +write changes only the typed leaf value, preserving its span and all unrelated +settings structure; inserting or resizing a source list is rejected so an +edit cannot silently become whole-tree regeneration. From 0c7239e4bd3bce8c6477d32fc5e11cf1d4e478ba Mon Sep 17 00:00:00 2001 From: Teakowa Date: Fri, 28 Aug 2026 16:36:13 +0800 Subject: [PATCH 2/2] feat(settings): expose ergonomic typed lookup and diagnostics Expose canonical identity lookup, source-located validation errors, and standard error traits for typed settings consumers. Add public API examples and regression coverage for invalid members and non-applicable targets. Fixes #111 --- crates/workshop-rs/src/settings/mod.rs | 2 +- crates/workshop-rs/src/settings/schema.rs | 46 +++++++++++++++- crates/workshop-rs/tests/settings_pipeline.rs | 50 +++++++++++++++-- docs/language-support/settings.md | 55 +++++++++++++++++-- 4 files changed, 143 insertions(+), 10 deletions(-) diff --git a/crates/workshop-rs/src/settings/mod.rs b/crates/workshop-rs/src/settings/mod.rs index 5a39c0a..18b5ec7 100644 --- a/crates/workshop-rs/src/settings/mod.rs +++ b/crates/workshop-rs/src/settings/mod.rs @@ -15,7 +15,7 @@ pub use schema::{ Applicability, EffectiveNumber, NumericBounds, NumericBoundsError, SettingDefinition, SettingEvidenceKind, SettingId, SettingIdentity, SettingOccurrence, SettingOperationError, SettingPresentation, SettingProvenance, SettingScope, SettingTarget, SettingTargetKind, - SettingValue, SettingValueDomain, TeamId, definition, definitions, + SettingValue, SettingValueDomain, TeamId, definition, definitions, definitions_by_id, }; use crate::source::Span; diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index e0dd039..934cd50 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -250,6 +250,39 @@ pub enum SettingOperationError { }, } +impl fmt::Display for SettingOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotApplicable { setting, target } => { + write!( + formatter, + "setting {setting} does not apply to target {target:?}" + ) + } + Self::NotFound { setting, target } => { + write!( + formatter, + "setting {setting} was not found for target {target:?}" + ) + } + Self::WrongValueKind { + setting, + expected, + actual, + .. + } => write!( + formatter, + "setting {setting} expects {expected} value, got {actual}" + ), + Self::InvalidValue { + setting, message, .. + } => write!(formatter, "invalid value for setting {setting}: {message}"), + } + } +} + +impl std::error::Error for SettingOperationError {} + impl SettingValueDomain { /// Apply evidenced effective clamping without changing the authored /// value held by [`super::SettingsNode`]. @@ -561,7 +594,6 @@ impl SettingDefinition { ) -> Result<(), SettingOperationError> { let id = self.operation_id()?; self.ensure_target(target)?; - validate_value(&self.domain, &id, &value, None)?; let path = self.concrete_path(target); let node = find_node_mut(&mut settings.children, &path).ok_or_else(|| { SettingOperationError::NotFound { @@ -569,6 +601,8 @@ impl SettingDefinition { target: target.clone(), } })?; + let span = node.span(); + validate_value(&self.domain, &id, &value, span)?; apply_value(node, &id, value) } @@ -896,6 +930,16 @@ pub fn definition(path: &[PathPart<'_>]) -> Option { table::lookup(path).map(SettingDefinition::from_entry) } +/// Find all definitions for a canonical concept identity. +/// +/// A concept can intentionally have more than one target shape, so the +/// result is an iterator rather than a single definition. This keeps normal +/// consumers independent of the private table paths while retaining the +/// target-specific schema facts. +pub fn definitions_by_id(id: &SettingId) -> impl Iterator { + definitions().filter(move |definition| definition.id() == Some(id)) +} + impl SettingDefinition { fn from_entry(entry: &TableEntry) -> Self { let scope = scope_for(entry.path); diff --git a/crates/workshop-rs/tests/settings_pipeline.rs b/crates/workshop-rs/tests/settings_pipeline.rs index 0ce932c..9e38ea1 100644 --- a/crates/workshop-rs/tests/settings_pipeline.rs +++ b/crates/workshop-rs/tests/settings_pipeline.rs @@ -8,7 +8,7 @@ use workshop_rs::gameplay::{AbilityVariant, HeroId, LogicalSlot, hero_ids, slots use workshop_rs::settings::{ Applicability, NumericBounds, SettingEvidenceKind, SettingId, SettingIdentity, SettingOperationError, SettingScope, SettingTarget, SettingTargetKind, SettingValue, - SettingValueDomain, TeamId, definitions, + SettingValueDomain, TeamId, definitions, definitions_by_id, }; use workshop_rs::{convert, emitter, parser, roundtrip, semantic}; @@ -695,8 +695,8 @@ fn typed_settings_read_and_write_preserve_unrelated_structure() { } }"#; let mut program = parser::parse(source, &catalog, &Locale::new("en-US")).expect("parse"); - let lobby = definitions() - .find(|definition| definition.path() == "lobby.spectatorSlots") + let lobby = definitions_by_id(&SettingId::from("setting.lobby.spectatorSlots")) + .next() .expect("lobby definition"); let read = lobby .read( @@ -756,6 +756,48 @@ fn typed_settings_read_and_write_preserve_unrelated_structure() { .expect_err("wrong kind must be rejected"); assert!(matches!( error, - SettingOperationError::WrongValueKind { .. } + SettingOperationError::WrongValueKind { span: Some(_), .. } )); } + +#[test] +fn typed_settings_errors_reject_invalid_members_and_non_applicable_targets() { + let catalog = Catalog::builtin().expect("catalog"); + let mut program = parser::parse( + "settings { modes { Assault { Limit Roles: 2 Of Each Role Per Team } } }", + &catalog, + &Locale::new("en-US"), + ) + .expect("parse"); + let role_limit = definitions_by_id(&SettingId::from("setting.gameMode.roleLimit")) + .find(|definition| definition.path().ends_with("assault.roleLimit")) + .expect("role-limit definition"); + let error = role_limit + .write( + program.settings.as_mut().expect("settings"), + &SettingTarget::Mode("assault".to_string()), + SettingValue::Enum("unknownRoleLimit".to_string()), + ) + .expect_err("unknown enum member must be rejected"); + assert!(matches!( + error, + SettingOperationError::InvalidValue { span: Some(_), .. } + )); + + let ashe_only = definitions() + .find(|definition| definition.path().ends_with("ability1EnemyKb%")) + .expect("Ashe-only setting"); + let error = ashe_only + .write( + program.settings.as_mut().expect("settings"), + &SettingTarget::HeroAbility { + team: None, + hero: HeroId::from(hero_ids::ANA), + slot: LogicalSlot::from(slots::ABILITY_1), + variant: None, + }, + SettingValue::Percent(10.0), + ) + .expect_err("non-applicable hero setting must be rejected"); + assert!(matches!(error, SettingOperationError::NotApplicable { .. })); +} diff --git a/docs/language-support/settings.md b/docs/language-support/settings.md index 5fd26c8..188ff36 100644 --- a/docs/language-support/settings.md +++ b/docs/language-support/settings.md @@ -23,11 +23,58 @@ 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}; +use workshop_rs::gameplay::{hero_ids, slots, HeroId, LogicalSlot}; +use workshop_rs::settings::{ + definitions_by_id, Applicability, NumericBounds, SettingId, SettingTarget, + SettingTargetKind, SettingValueDomain, TeamId, +}; -let health = definitions() - .find(|definition| definition.id().is_some_and(|id| id.as_str() == "setting.hero.health")) - .expect("canonical hero health setting"); +let lobby = definitions_by_id(&SettingId::from("setting.lobby.spectatorSlots")) + .next() + .expect("canonical lobby setting"); +assert!(matches!(lobby.domain(), SettingValueDomain::Number(_))); + +let hero_ability = definitions_by_id(&SettingId::from("setting.hero.ability.enabled")) + .find(|definition| { + matches!(definition.target_kind(), SettingTargetKind::HeroAbility { .. }) + }) + .expect("canonical hero ability setting"); +let dva_primary = SettingTarget::HeroAbility { + team: Some(TeamId::new("allTeams")), + hero: HeroId::from(hero_ids::DVA), + slot: LogicalSlot::from(slots::PRIMARY_FIRE), + variant: None, +}; +assert_eq!( + hero_ability.applicability(&dva_primary).expect("applicability"), + Applicability::Applicable +); + +let ashe_only = definitions_by_id(&SettingId::from("setting.hero.ability.knockback.enemy")) + .find(|definition| definition.path().ends_with("ability1EnemyKb%")) + .expect("exceptional hero setting"); +let ana_ability = SettingTarget::HeroAbility { + team: None, + hero: HeroId::from(hero_ids::ANA), + slot: LogicalSlot::from(slots::ABILITY_1), + variant: None, +}; +assert_eq!( + ashe_only.applicability(&ana_ability).expect("applicability"), + Applicability::NotApplicable +); + +// A definition whose reviewed evidence exposes bounds reports both values; +// the authored source value remains unchanged until an explicit write. +let evidenced = SettingValueDomain::Percent( + NumericBounds::new(Some(0.0), Some(500.0)).expect("valid bounds"), +); +let effective = evidenced.effective_number(650.0).expect("clamped value"); +assert_eq!((effective.authored, effective.effective), (650.0, 500.0)); + +let health = definitions_by_id(&SettingId::from("setting.hero.health")) + .next() + .expect("canonical hero setting"); assert!(matches!(health.domain(), SettingValueDomain::Percent(_))); assert!(health .applicability(&SettingTarget::Hero {