From 1523e5c30333923c5113e4e67e205f1b2c361779 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 22:03:08 +0800 Subject: [PATCH] fix(parser): preserve Team constants in Color slots Refs #306 --- crates/workshop-rs/src/values/parser.rs | 24 +++++---- crates/workshop-rs/src/values/validate.rs | 52 ++++++++++++++----- .../workshop-rs/tests/contextual_semantics.rs | 27 ++++++++++ crates/workshop-rs/tests/wrapper_forms.rs | 44 +++++++++++++--- docs/wrapper-forms.md | 11 ++-- 5 files changed, 125 insertions(+), 33 deletions(-) diff --git a/crates/workshop-rs/src/values/parser.rs b/crates/workshop-rs/src/values/parser.rs index 10f82d0..07a4af5 100644 --- a/crates/workshop-rs/src/values/parser.rs +++ b/crates/workshop-rs/src/values/parser.rs @@ -734,15 +734,21 @@ impl ParseContext<'_> { Some(Span::new(self.file(), start, end)), ))); } - if let Some(expected) = self.expected_domain { - if let Some((value_type, value)) = self.resolve_enum_member_mixed(expected, phrase) { - return Ok(self.target.values.push(ValueNode::new( - Value::Enum { value_type, value }, - Some(Span::new(self.file(), start, end)), - ))); - } - } - if let Some((value_type, value)) = self.resolve_enum_member_mixed("Team", phrase) { + let member = self + .expected_domain + .filter(|domain| *domain == "Color") + .and_then(|_| self.resolve_enum_member_mixed("Team", phrase)) + .filter(|(_, member)| { + self.catalog + .enum_spelling("Color", self.catalog.primary_locale(), member) + .is_some() + }) + .or_else(|| { + self.expected_domain + .and_then(|domain| self.resolve_enum_member_mixed(domain, phrase)) + }) + .or_else(|| self.resolve_enum_member_mixed("Team", phrase)); + if let Some((value_type, value)) = member { return Ok(self.target.values.push(ValueNode::new( Value::Enum { value_type, value }, Some(Span::new(self.file(), start, end)), diff --git a/crates/workshop-rs/src/values/validate.rs b/crates/workshop-rs/src/values/validate.rs index 6fc4da7..60695ef 100644 --- a/crates/workshop-rs/src/values/validate.rs +++ b/crates/workshop-rs/src/values/validate.rs @@ -257,12 +257,7 @@ pub(crate) fn validate_call_signature( let valid = match &node.value { wir::Value::Enum { value_type, value, .. - } => { - value_type == domain - && catalog - .enum_spelling(domain, catalog.primary_locale(), value) - .is_some() - } + } => enum_member_matches_domain(catalog, value_type, value, domain), wir::Value::Call { name, .. } if name == wir::AMBIGUOUS_ENUM_CALL => { wir::ambiguous_enum_parts(program, *arg_id).is_some_and(|(_, candidate_ids)| { candidate_ids.iter().any(|candidate_id| { @@ -271,10 +266,7 @@ pub(crate) fn validate_call_signature( Some(wir::ValueNode { value: wir::Value::Enum { value_type, value }, .. - }) if value_type == domain - && catalog - .enum_spelling(domain, catalog.primary_locale(), value) - .is_some() + }) if enum_member_matches_domain(catalog, value_type, value, domain) ) }) }) @@ -395,9 +387,14 @@ fn value_matches_type( matches!( program.values.get(*candidate_id), Some(wir::ValueNode { - value: wir::Value::Enum { value_type, .. }, + value: wir::Value::Enum { value_type, value }, .. - }) if value_type == alternative + }) if enum_type_matches_domain( + catalog, + value_type, + value, + alternative, + ) ) }) }, @@ -426,8 +423,9 @@ fn value_matches_single_type(catalog: &Catalog, value: &wir::Value, expected: &s | wir::Value::Vector { .. }, "Object", ) => true, - (wir::Value::Enum { value_type, .. }, domain) => { - matches!(domain, "Any" | "Unknown" | "Object") || value_type == domain + (wir::Value::Enum { value_type, value }, domain) => { + matches!(domain, "Any" | "Unknown" | "Object") + || enum_type_matches_domain(catalog, value_type, value, domain) } (wir::Value::Call { name, .. }, expected) => { if expected == "Operation" @@ -483,6 +481,32 @@ fn value_matches_single_type(catalog: &Catalog, value: &wir::Value, expected: &s } } +fn enum_type_matches_domain( + catalog: &Catalog, + value_type: &str, + value: &str, + expected_domain: &str, +) -> bool { + value_type == expected_domain + || (value_type == "Team" + && expected_domain == "Color" + && catalog + .enum_spelling(expected_domain, catalog.primary_locale(), value) + .is_some()) +} + +fn enum_member_matches_domain( + catalog: &Catalog, + value_type: &str, + value: &str, + expected_domain: &str, +) -> bool { + enum_type_matches_domain(catalog, value_type, value, expected_domain) + && catalog + .enum_spelling(expected_domain, catalog.primary_locale(), value) + .is_some() +} + fn semantic_types_compatible(actual: &str, expected: &str) -> bool { matches!(actual, "Any" | "Unknown") || matches!(expected, "Any" | "Unknown") diff --git a/crates/workshop-rs/tests/contextual_semantics.rs b/crates/workshop-rs/tests/contextual_semantics.rs index 0d07ce8..25f6da8 100644 --- a/crates/workshop-rs/tests/contextual_semantics.rs +++ b/crates/workshop-rs/tests/contextual_semantics.rs @@ -296,6 +296,33 @@ fn create_dummy_bot_accepts_hero_or_hero_array_only_for_hero_parameter() { assert!(error.to_string().contains("semantic type")); } +#[test] +fn team_members_shared_with_color_are_valid_in_color_slots() { + let accepted = program( + "Play Effect(All Players(All Teams), Echo Sticky Bomb Explosion Effect, Team 2, Event Player, 200);", + ); + validate_program(&accepted); + assert!(matches!( + &accepted + .values + .get(first_action_args(&accepted)[2]) + .expect("play effect color") + .value, + Value::Enum { value_type, value } if value_type == "Team" && value == "TEAM_2" + )); + + let unsupported = program( + "Play Effect(All Players(All Teams), Echo Sticky Bomb Explosion Effect, All Teams, Event Player, 200);", + ); + let error = validate::validate_canonical_ids_wir(&unsupported, &catalog()) + .expect_err("a Team member absent from Color must still be rejected in a Color slot"); + assert!( + error + .to_string() + .contains("semantic type 'Color', got Team") + ); +} + #[test] fn nested_numeric_boolean_aliases_are_preserved_inside_vector_components() { let program = program("Set Global Variable(probe, Vector(1, True, False));"); diff --git a/crates/workshop-rs/tests/wrapper_forms.rs b/crates/workshop-rs/tests/wrapper_forms.rs index 4b468f7..3f48b03 100644 --- a/crates/workshop-rs/tests/wrapper_forms.rs +++ b/crates/workshop-rs/tests/wrapper_forms.rs @@ -94,6 +94,34 @@ fn a_team_wrapper_in_the_input_is_written_bare_in_every_locale() { } } +#[test] +fn team_constants_in_color_slots_keep_their_authored_form() { + for (locale, section, event, actions) in [ + ( + "en-US", + "rule", + "event", + &[ + "Play Effect(All Players(All Teams), Echo Sticky Bomb Explosion Effect, Team 1, Event Player, 200);", + "Play Effect(All Players(All Teams), Echo Sticky Bomb Explosion Effect, Team 2, Event Player, 200);", + "Play Effect(All Players(All Teams), Echo Sticky Bomb Explosion Effect, Color(Team 2), Event Player, 200);", + ][..], + ), + ( + "zh-CN", + "规则", + "事件", + &[ + "播放效果(所有玩家(所有队伍), “回声”黏性炸弹爆炸效果, 队伍1, 事件玩家, 200);", + "播放效果(所有玩家(所有队伍), “回声”黏性炸弹爆炸效果, 队伍2, 事件玩家, 200);", + "播放效果(所有玩家(所有队伍), “回声”黏性炸弹爆炸效果, 颜色(队伍2), 事件玩家, 200);", + ][..], + ), + ] { + assert_lines_preserved(actions, locale, event, section); + } +} + #[test] fn yes_no_settings_are_written_with_the_client_word() { for (locale, text) in [ @@ -111,13 +139,15 @@ fn yes_no_settings_are_written_with_the_client_word() { } fn wrapper_counts(text: &str) -> Vec { - ["Team", "队伍", "Hero", "英雄", "Button", "按钮"] - .iter() - .map(|name| { - let pattern = format!(r"(^|[^\p{{L}}\p{{N}}_]){name}\("); - regex::Regex::new(&pattern).unwrap().find_iter(text).count() - }) - .collect() + [ + "Team", "队伍", "Hero", "英雄", "Button", "按钮", "Color", "颜色", + ] + .iter() + .map(|name| { + let pattern = format!(r"(^|[^\p{{L}}\p{{N}}_]){name}\("); + regex::Regex::new(&pattern).unwrap().find_iter(text).count() + }) + .collect() } fn tokens(text: &str) -> BTreeSet { diff --git a/docs/wrapper-forms.md b/docs/wrapper-forms.md index fdab68f..0615ab0 100644 --- a/docs/wrapper-forms.md +++ b/docs/wrapper-forms.md @@ -29,12 +29,17 @@ elements, comparisons, and generic parameters alike. `Team(Team 1)` is never written. `Game Mode(...)` is a value function in the catalog, not a constant wrapper, and is preserved as parsed. +`Team 1` and `Team 2` are also Color members. In a Color-typed slot, their bare +spelling resolves to the Team domain and stays bare; `Color(Team 2)` explicitly +selects the Color member and stays wrapped. This preserves the domain expressed +by the authored form when a slot accepts a value from another enum domain. + Evidence: pinned OverPy 9.7.10 compiled a probe covering each domain in Team-, Hero-, Array-, `Compare`-, and `Custom String`-argument positions, in en-US and zh-CN, and always wrote the forms above. The real-project fixtures -generated by OverPy keep their `Hero(`/`Button(` call counts through parse and -emit (`tests/wrapper_forms.rs`). Localized wrapper names are the domain aliases -in the catalog (`颜色`, `英雄`, `按钮`, `地图`). +generated by OverPy keep their `Team(`/`Hero(`/`Button(`/`Color(` call counts +through parse and emit (`tests/wrapper_forms.rs`). Localized wrapper names are +the domain aliases in the catalog (`颜色`, `英雄`, `按钮`, `地图`). ## Boolean settings