From 8cc222eb6b532061fd0d5a50a3943f35b5a4b93f Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 16:51:39 +0800 Subject: [PATCH 1/4] fix(emitter)!: emit constant wrappers and yes/no settings the way the client accepts them Hero, Button, Color and Map constants are always written with their wrapper and Team members bare, in every position, emit path and locale, matching pinned OverPy 9.7.10. Lobby settings that take yes/no are written Yes/No. The ten zh-CN action spellings that differed from OverPy now emit the OverPy spelling and keep the old one as a parse alias. Adds docs/wrapper-forms.md and regression coverage. Fixes #302 --- crates/workshop-rs/src/actions/layout.rs | 1 - .../src/bin/workshop-catalog-gen.rs | 1 + .../workshop-rs/src/catalog/data/catalog.json | 42 ++-- crates/workshop-rs/src/output/convert.rs | 2 +- crates/workshop-rs/src/output/emitter.rs | 20 +- .../src/settings/data/locales.json | 24 ++- .../data/projection_reconciliation.json | 2 + crates/workshop-rs/src/settings/emitter.rs | 8 + crates/workshop-rs/src/settings/parser.rs | 2 +- crates/workshop-rs/src/settings/schema.rs | 6 +- crates/workshop-rs/src/settings/table.rs | 6 +- crates/workshop-rs/src/tests.rs | 2 + crates/workshop-rs/src/values/emitter.rs | 24 +-- crates/workshop-rs/tests/corpus.rs | 4 +- crates/workshop-rs/tests/identity.rs | 2 +- crates/workshop-rs/tests/wrapper_forms.rs | 185 ++++++++++++++++++ docs/README.md | 2 + docs/source-attribution/catalog.md | 1 + docs/wrapper-forms.md | 53 +++++ 19 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 crates/workshop-rs/tests/wrapper_forms.rs create mode 100644 docs/wrapper-forms.md diff --git a/crates/workshop-rs/src/actions/layout.rs b/crates/workshop-rs/src/actions/layout.rs index e61d611..4581b15 100644 --- a/crates/workshop-rs/src/actions/layout.rs +++ b/crates/workshop-rs/src/actions/layout.rs @@ -105,7 +105,6 @@ fn action_width_wir( locale: locale.clone(), fallback: None, fallback_ids: Vec::new(), - force_hero_constructors: false, out: String::new(), line_count: 0, }; diff --git a/crates/workshop-rs/src/bin/workshop-catalog-gen.rs b/crates/workshop-rs/src/bin/workshop-catalog-gen.rs index ace2af9..e8a3ff5 100644 --- a/crates/workshop-rs/src/bin/workshop-catalog-gen.rs +++ b/crates/workshop-rs/src/bin/workshop-catalog-gen.rs @@ -677,6 +677,7 @@ mod corpus { let tokens = [ ("token.on", "On"), ("token.off", "Off"), + ("token.yes", "Yes"), ("token.no", "No"), ("token.disabled", "disabled"), ] diff --git a/crates/workshop-rs/src/catalog/data/catalog.json b/crates/workshop-rs/src/catalog/data/catalog.json index 527b9d5..a5c71ff 100644 --- a/crates/workshop-rs/src/catalog/data/catalog.json +++ b/crates/workshop-rs/src/catalog/data/catalog.json @@ -3796,8 +3796,8 @@ "th-TH": "เปิดใช้งานเสียงเพลงโหมดเกมในตัว", "tr-TR": "Dâhilî Oyun Modu Müziğini Etkinleştir", "zh-CN": [ - "启用内置游戏模式音乐", - "开启游戏预设音乐模式" + "开启游戏预设音乐模式", + "启用内置游戏模式音乐" ], "zh-TW": "Enable Built-In Game Mode Music" }, @@ -3821,8 +3821,8 @@ "th-TH": "ทำลายข้อความในโลกทั้งหมด", "tr-TR": "Tüm Oyun İçi Metinleri Yok Et", "zh-CN": [ - "摧毁所有世界内文本", - "消除所有地图文本" + "消除所有地图文本", + "摧毁所有世界内文本" ], "zh-TW": "Destroy All In-World Text" }, @@ -3846,8 +3846,8 @@ "th-TH": "ทำลายไอคอนทั้งหมด", "tr-TR": "Tüm Simgeleri Yok Et", "zh-CN": [ - "摧毁所有图标", - "消除所有图标" + "消除所有图标", + "摧毁所有图标" ], "zh-TW": "Destroy All Icons" }, @@ -3871,8 +3871,8 @@ "th-TH": "ทำลายข้อความ HUD ทั้งหมด", "tr-TR": "Tüm Ekran Göstergeleri Metinlerini Yok Et", "zh-CN": [ - "摧毁所有HUD文本", - "消除所有HUD文本" + "消除所有HUD文本", + "摧毁所有HUD文本" ], "zh-TW": "Destroy All HUD Text" }, @@ -3896,8 +3896,8 @@ "th-TH": "ทําลายเอฟเฟกต์ทั้งหมด", "tr-TR": "Tüm Efektleri Yok Et", "zh-CN": [ - "摧毁所有效果", - "消除所有效果" + "消除所有效果", + "摧毁所有效果" ], "zh-TW": "Destroy All Effects" }, @@ -3921,8 +3921,8 @@ "th-TH": "ปิดใช้งานเพลงโหมดเกมในตัว", "tr-TR": "Dâhilî Oyun Modu Müziğini Devre Dışı Bırak", "zh-CN": [ - "禁用内置游戏模式音乐", - "关闭游戏预设音乐模式" + "关闭游戏预设音乐模式", + "禁用内置游戏模式音乐" ], "zh-TW": "Disable Built-In Game Mode Music" }, @@ -3971,8 +3971,8 @@ "th-TH": "ปิดใช้งานการจบโหมดเกมในตัว", "tr-TR": "Dâhilî Oyun Modu Sonuçlandırma'yı Devre Dışı Bırak", "zh-CN": [ - "禁用内置游戏模式完成", - "关闭游戏预设完成条件" + "关闭游戏预设完成条件", + "禁用内置游戏模式完成" ], "zh-TW": "Disable Built-In Game Mode Completion" }, @@ -3996,8 +3996,8 @@ "th-TH": "ปิดใช้งานผู้ประกาศโหมดเกมในตัว", "tr-TR": "Dâhilî Oyun Modu Anonsçusunu Devre Dışı Bırak", "zh-CN": [ - "禁用内置游戏模式播报员", - "关闭游戏预设通告模式" + "关闭游戏预设通告模式", + "禁用内置游戏模式播报员" ], "zh-TW": "Disable Built-In Game Mode Announcer" }, @@ -4021,8 +4021,8 @@ "th-TH": "ปิดใช้งานการให้คะแนนโหมดเกมในตัว", "tr-TR": "Dâhilî Oyun Modu Puanlama'yı Devre Dışı Bırak", "zh-CN": [ - "禁用内置游戏模式计分", - "关闭游戏预设计分模式" + "关闭游戏预设计分模式", + "禁用内置游戏模式计分" ], "zh-TW": "Disable Built-In Game Mode Scoring" }, @@ -4046,8 +4046,8 @@ "th-TH": "เลิกหยุดเวลาแมตช์ชั่วคราว", "tr-TR": "Maç Süresini Sürdür", "zh-CN": [ - "继续比赛时间", - "比赛时间继续" + "比赛时间继续", + "继续比赛时间" ], "zh-TW": "Unpause Match Time" }, @@ -7888,7 +7888,7 @@ ] } ], - "digest": "e022d56f95c4870a4e1abfeb82b33674da3a8e983f9aed986b21285e181a404d", + "digest": "4a2702e63611573541c0880b942fed0cfec63190ef44f994739174e21d10e000", "enums": [ { "domain": "Impulse", diff --git a/crates/workshop-rs/src/output/convert.rs b/crates/workshop-rs/src/output/convert.rs index a6f1ce8..93f0f46 100644 --- a/crates/workshop-rs/src/output/convert.rs +++ b/crates/workshop-rs/src/output/convert.rs @@ -64,7 +64,7 @@ pub fn convert_with_context( let emit_options = EmitOptions { fallback_locale: options.fallback_locale.clone(), }; - let output = emitter::emit_with_options_for_conversion(&program, catalog, to, &emit_options)?; + let output = emitter::emit_with_options(&program, catalog, to, &emit_options)?; Ok(Conversion { text: output.text, fallback_ids: output.fallback_ids, diff --git a/crates/workshop-rs/src/output/emitter.rs b/crates/workshop-rs/src/output/emitter.rs index 3a91d87..6e88b84 100644 --- a/crates/workshop-rs/src/output/emitter.rs +++ b/crates/workshop-rs/src/output/emitter.rs @@ -51,8 +51,7 @@ pub(crate) fn emit_wir( catalog: &Catalog, locale: &Locale, ) -> Result { - emit_with_options_inner(program, catalog, locale, &EmitOptions::default(), false) - .map(|out| out.text) + emit_with_options_inner(program, catalog, locale, &EmitOptions::default()).map(|out| out.text) } /// Emit a public Workshop program as localized Workshop text with emission @@ -64,17 +63,7 @@ pub fn emit_with_options( options: &EmitOptions, ) -> Result { let storage = program.to_wir()?; - emit_with_options_inner(&storage, catalog, locale, options, false) -} - -pub(crate) fn emit_with_options_for_conversion( - program: &crate::Program, - catalog: &Catalog, - locale: &Locale, - options: &EmitOptions, -) -> Result { - let storage = program.to_wir()?; - emit_with_options_inner(&storage, catalog, locale, options, true) + emit_with_options_inner(&storage, catalog, locale, options) } #[cfg(test)] @@ -84,7 +73,7 @@ pub(crate) fn emit_wir_with_options( locale: &Locale, options: &EmitOptions, ) -> Result { - emit_with_options_inner(program, catalog, locale, options, false) + emit_with_options_inner(program, catalog, locale, options) } fn emit_with_options_inner( @@ -92,14 +81,12 @@ fn emit_with_options_inner( catalog: &Catalog, locale: &Locale, options: &EmitOptions, - force_hero_constructors: bool, ) -> Result { let mut emitter = EmitContext { program, catalog, locale: locale.clone(), fallback: options.fallback_locale.clone(), - force_hero_constructors, fallback_ids: Vec::new(), out: String::new(), line_count: 0, @@ -119,7 +106,6 @@ pub(crate) struct EmitContext<'a> { pub(crate) fallback: Option, /// Canonical ids emitted with a fallback-locale spelling. pub(crate) fallback_ids: Vec, - pub(crate) force_hero_constructors: bool, pub(crate) out: String, pub(crate) line_count: usize, } diff --git a/crates/workshop-rs/src/settings/data/locales.json b/crates/workshop-rs/src/settings/data/locales.json index 19c4f76..454ee75 100644 --- a/crates/workshop-rs/src/settings/data/locales.json +++ b/crates/workshop-rs/src/settings/data/locales.json @@ -29,8 +29,8 @@ "total": 3 }, "tokens": { - "matched": 4, - "total": 4 + "matched": 5, + "total": 5 } }, "enums": { @@ -9370,6 +9370,26 @@ "zh-CN": "开启", "zh-TW": "開啟" }, + "Yes": { + "de-DE": "Ja", + "en-US": "Yes", + "es-ES": "Sí", + "es-MX": "Sí", + "fr-FR": "Oui", + "it-IT": "Sì", + "ja-JP": "はい", + "ko-KR": "네", + "pl-PL": "Tak", + "pt-BR": "Sim", + "ru-RU": "Да", + "sources": [ + "other.customGameSettings.__yes__" + ], + "th-TH": "ใช่", + "tr-TR": "Evet", + "zh-CN": "是", + "zh-TW": "是" + }, "disabled": { "de-DE": "Deaktiviert", "en-US": "disabled", diff --git a/crates/workshop-rs/src/settings/data/projection_reconciliation.json b/crates/workshop-rs/src/settings/data/projection_reconciliation.json index c2a222e..fc1dc9e 100644 --- a/crates/workshop-rs/src/settings/data/projection_reconciliation.json +++ b/crates/workshop-rs/src/settings/data/projection_reconciliation.json @@ -9,9 +9,11 @@ { "path": "heroes...enableAbility2", "fixture": { "name": "Ability 2", "kind": "bool" }, "generated": { "name": "%1$s", "kind": "bool" } }, { "path": "heroes...enableSecondaryFire", "fixture": { "name": "Secondary Fire", "kind": "bool" }, "generated": { "name": "%1$s", "kind": "bool" } }, { "path": "heroes...passiveUltGen%", "fixture": { "name": "Ultimate Generation - Passive Blizzard", "kind": "percent" }, "generated": { "name": "Ultimate Generation - Passive %1$s", "kind": "percent" } }, + { "path": "lobby.allowPlayersInQueue", "fixture": { "name": "Allow Players Who Are In Queue", "kind": "yesNo" }, "generated": { "name": "Allow Players Who Are In Queue", "kind": "bool" } }, { "path": "lobby.enableMatchVoiceChat", "fixture": { "name": "Match Voice Chat", "kind": "boolEnum", "domain": "matchVoiceChat" }, "generated": { "name": "Match Voice Chat", "kind": "bool" } }, { "path": "lobby.mapRotation", "fixture": { "name": "Map Rotation", "kind": "enum", "domain": "mapRotation" }, "generated": { "name": "Map Rotation", "kind": "enum", "domain": "setting_lobby_mapRotation" } }, { "path": "lobby.returnToLobby", "fixture": { "name": "Return To Lobby", "kind": "enum", "domain": "returnToLobby" }, "generated": { "name": "Return To Lobby", "kind": "enum", "domain": "setting_lobby_returnToLobby" } }, + { "path": "lobby.swapTeamsAfterMatch", "fixture": { "name": "Swap Teams After Match", "kind": "yesNo" }, "generated": { "name": "Swap Teams After Match", "kind": "bool" } }, { "path": "lobby.team1Slots", "fixture": { "name": "Max Team 1 Players", "kind": "number" }, "generated": { "name": "Max %1$s Players", "kind": "number" } }, { "path": "lobby.team2Slots", "fixture": { "name": "Max Team 2 Players", "kind": "number" }, "generated": { "name": "Max %1$s Players", "kind": "number" } } ], diff --git a/crates/workshop-rs/src/settings/emitter.rs b/crates/workshop-rs/src/settings/emitter.rs index 89e33d4..9a146ef 100644 --- a/crates/workshop-rs/src/settings/emitter.rs +++ b/crates/workshop-rs/src/settings/emitter.rs @@ -252,6 +252,14 @@ impl EmitContext<'_> { )?; self.line(level, &format!("{display_name}: {rendered}"))?; } + (SettingsNode::Bool { value, .. }, KeyKind::YesNo) => { + let rendered = self.setting_name( + "tokens", + if *value { "Yes" } else { "No" }, + if *value { "token.yes" } else { "token.no" }, + )?; + self.line(level, &format!("{display_name}: {rendered}"))?; + } (SettingsNode::Bool { value, .. }, KeyKind::BoolEnum(domain)) => { if !*value { return Err(self diff --git a/crates/workshop-rs/src/settings/parser.rs b/crates/workshop-rs/src/settings/parser.rs index ef13fc6..2bfcc03 100644 --- a/crates/workshop-rs/src/settings/parser.rs +++ b/crates/workshop-rs/src/settings/parser.rs @@ -317,7 +317,7 @@ impl ParseContext<'_> { span: Some(Span::new(self.file(), start, self.previous_span().1)), }) } - KeyKind::Bool => { + KeyKind::Bool | KeyKind::YesNo => { let value = self.settings_bool()?; Ok(SettingsNode::Bool { name: name.to_string(), diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index dbf4c27..cac3ec1 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -900,6 +900,9 @@ fn source_value_spelling( (SettingValueDomain::Boolean, KeyKind::Bool, SettingValue::Boolean(value)) => { localized("tokens", if value { "On" } else { "Off" }) } + (SettingValueDomain::Boolean, KeyKind::YesNo, SettingValue::Boolean(value)) => { + localized("tokens", if value { "Yes" } else { "No" }) + } (SettingValueDomain::Boolean, KeyKind::BoolEnum(domain), SettingValue::Boolean(true)) => { let english = table::enum_name(domain, "enabled").ok_or_else(|| { SettingOperationError::InvalidValue { @@ -1343,7 +1346,7 @@ fn domain_for(kind: KeyKind) -> SettingValueDomain { match kind { KeyKind::Flag => SettingValueDomain::PresenceOnly, KeyKind::String => SettingValueDomain::String, - KeyKind::Bool | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean, + KeyKind::Bool | KeyKind::YesNo | KeyKind::BoolEnum(_) => SettingValueDomain::Boolean, KeyKind::Number => SettingValueDomain::Number(NumericBounds::unknown()), KeyKind::Percent => SettingValueDomain::Percent(NumericBounds::unknown()), KeyKind::Enum(domain) => SettingValueDomain::Enum { @@ -1536,6 +1539,7 @@ fn key_kind_matches(kind: KeyKind, expected: &reconciliation::EntryContract) -> (KeyKind::Flag, "flag", None) | (KeyKind::String, "string", None) | (KeyKind::Bool, "bool", None) + | (KeyKind::YesNo, "yesNo", None) | (KeyKind::Number, "number", None) | (KeyKind::Percent, "percent", None) | (KeyKind::ListMap, "mapList", None) diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index 6f8a025..058e6cd 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -53,6 +53,8 @@ pub(crate) enum KeyKind { String, /// A boolean rendered `On`/`Off`. Bool, + /// A boolean rendered `Yes`/`No`. + YesNo, /// A boolean carrier rendered through a source-supported true-value enum /// token. The false value remains unsupported until independently sourced. BoolEnum(&'static str), @@ -155,7 +157,7 @@ pub(crate) static ENTRIES: &[TableEntry] = &[ PathPart::Part("allowPlayersInQueue") ], "Allow Players Who Are In Queue", - KeyKind::Bool + KeyKind::YesNo ), entry!( [ @@ -163,7 +165,7 @@ pub(crate) static ENTRIES: &[TableEntry] = &[ PathPart::Part("swapTeamsAfterMatch") ], "Swap Teams After Match", - KeyKind::Bool + KeyKind::YesNo ), // gamemodes. — per-key subsets (exact-path entries, #86): // enabledMaps under modes {assault, control, escort, hybrid, skirmish, diff --git a/crates/workshop-rs/src/tests.rs b/crates/workshop-rs/src/tests.rs index 9df8961..48cf5ed 100644 --- a/crates/workshop-rs/src/tests.rs +++ b/crates/workshop-rs/src/tests.rs @@ -28,3 +28,5 @@ mod source_map; mod source_preservation; #[path = "../tests/wir_expansion.rs"] mod wir_expansion; +#[path = "../tests/wrapper_forms.rs"] +mod wrapper_forms; diff --git a/crates/workshop-rs/src/values/emitter.rs b/crates/workshop-rs/src/values/emitter.rs index 826ce5b..7be0759 100644 --- a/crates/workshop-rs/src/values/emitter.rs +++ b/crates/workshop-rs/src/values/emitter.rs @@ -57,26 +57,10 @@ impl<'a> EmitContext<'a> { } wir::Value::Enum { value_type, value } => { let spelling = self.enum_spelling(value_type, value)?; - // Color, Team, and Hero values use the constructor form; - // other domains use bare member spellings (the canonical - // corpus form). The - // Team/Color spelling collision (`Team 2` is both a Team and - // a Team color) is the one ambiguity unpinned by the - // catalog's paramDomains, so Team members qualify with the - // constructor form and the emitted text reparses - // deterministically (round-trip contract; pinned P4 - // evidence). - if matches!(value_type.as_str(), "Color" | "Map" | "Team") - || value_type == "Hero" - && (spelling.contains('.') - || self.locale != *self.catalog.primary_locale() - || (self.force_hero_constructors - && self - .program - .global_variables - .iter() - .any(|variable| variable.name == spelling))) - { + // Constructor-form domains are written `Domain(Member)` in + // every position and locale; Team members and every other + // domain are written bare (docs/wrapper-forms.md). + if matches!(value_type.as_str(), "Hero" | "Button" | "Color" | "Map") { let domain = self .catalog .enum_domain(value_type) diff --git a/crates/workshop-rs/tests/corpus.rs b/crates/workshop-rs/tests/corpus.rs index 4a9df8a..7555f5f 100644 --- a/crates/workshop-rs/tests/corpus.rs +++ b/crates/workshop-rs/tests/corpus.rs @@ -171,7 +171,7 @@ fn confirmed_set_allowed_heroes_mapping_converts_in_both_directions() { Ongoing - Global; } actions { - Set Player Allowed Heroes(All Players(Team(All Teams)), Ana); + Set Player Allowed Heroes(All Players(All Teams), Hero(Ana)); } } "; @@ -206,7 +206,7 @@ rule (\"legacy-aliases\") { } actions { Stop Chasing Global Variable(Global.value); - Start Forcing Player To Be Hero(Event Player, Ana); + Start Forcing Player To Be Hero(Event Player, Hero(Ana)); Stop Forcing Player To Be Hero(Event Player); Start Forcing Throttle(Event Player, 100, 100, 100, 100, 100, 100); } diff --git a/crates/workshop-rs/tests/identity.rs b/crates/workshop-rs/tests/identity.rs index 445577d..8e044ee 100644 --- a/crates/workshop-rs/tests/identity.rs +++ b/crates/workshop-rs/tests/identity.rs @@ -12,7 +12,7 @@ use workshop_rs::catalog::{Catalog, Locale}; /// (`workshop-catalog-gen build`) recomputes it and the pin is updated /// deliberately together with the data. const PINNED_CATALOG_DIGEST: &str = - "e022d56f95c4870a4e1abfeb82b33674da3a8e983f9aed986b21285e181a404d"; + "4a2702e63611573541c0880b942fed0cfec63190ef44f994739174e21d10e000"; #[test] fn committed_catalog_digest_is_pinned() { diff --git a/crates/workshop-rs/tests/wrapper_forms.rs b/crates/workshop-rs/tests/wrapper_forms.rs new file mode 100644 index 0000000..5fec8b5 --- /dev/null +++ b/crates/workshop-rs/tests/wrapper_forms.rs @@ -0,0 +1,185 @@ +//! Constant wrapper and settings-boolean forms (docs/wrapper-forms.md). + +use std::collections::BTreeSet; + +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::emitter; +use workshop_rs::parser; + +use super::common; + +fn round_trip(text: &str, locale: &str) -> String { + let catalog = Catalog::builtin().unwrap(); + let locale = Locale::new(locale); + let program = parser::parse_wir_with_context(text, &catalog, &locale, &catalog) + .unwrap_or_else(|error| panic!("parse failed: {error:?}\n{text}")); + emitter::emit_wir(&program, &catalog, &locale) + .unwrap_or_else(|error| panic!("emit failed: {error:?}")) +} + +fn assert_lines_preserved(actions: &[&str], locale: &str, event: &str, section: &str) { + let body: String = actions + .iter() + .map(|line| format!(" {line}\n")) + .collect(); + let text = format!( + "{section} (\"t\") {{\n {event} {{\n {}\n }}\n actions {{\n{body} }}\n}}\n", + if locale == "zh-CN" { + "持续 - 全局;" + } else { + "Ongoing - Global;" + } + ); + let emitted = round_trip(&text, locale); + for line in actions { + assert!( + emitted.contains(&format!(" {line}\n")), + "{locale}: `{line}` was not preserved:\n{emitted}" + ); + } +} + +#[test] +fn overpy_constant_forms_round_trip_in_en_us() { + assert_lines_preserved( + &[ + "Set Global Variable(A, All Players(Team 1));", + "Set Global Variable(A, Players On Hero(Hero(Mercy), Team 1));", + "Set Global Variable(A, Array(Hero(D.Va), Hero(Reinhardt), Hero(Winston)));", + "Set Global Variable(A, Array(Team 1, Team 2));", + "Set Global Variable(A, Array(Color(Red), Color(Blue)));", + "Set Global Variable(A, Array(Map(Hanamura)));", + "Set Global Variable(A, Array(Button(Reload), Button(Jump)));", + "Set Global Variable(A, Compare(Global.A, ==, Team 1));", + "Small Message(Event Player, Custom String(\"x{0}\", Button(Reload)));", + ], + "en-US", + "event", + "rule", + ); +} + +#[test] +fn overpy_constant_forms_round_trip_in_zh_cn() { + assert_lines_preserved( + &[ + "设置全局变量(A, 所有玩家(队伍1));", + "设置全局变量(A, 选择英雄的玩家(英雄(天使), 队伍1));", + "设置全局变量(A, 数组(英雄(D.Va), 英雄(莱因哈特), 英雄(温斯顿)));", + "设置全局变量(A, 数组(队伍1, 队伍2));", + "设置全局变量(A, 数组(颜色(红色), 颜色(蓝色)));", + "设置全局变量(A, 数组(地图(花村)));", + "设置全局变量(A, 数组(按钮(装填), 按钮(跳跃)));", + "设置全局变量(A, 比较(全局.A, ==, 队伍1));", + "小字体信息(事件玩家, 自定义字符串(\"x{0}\", 按钮(装填)));", + ], + "zh-CN", + "事件", + "规则", + ); +} + +#[test] +fn a_team_wrapper_in_the_input_is_written_bare_in_every_locale() { + let text = "rule (\"t\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(A, All Players(Team(Team 1)));\n }\n}\n"; + let catalog = Catalog::builtin().unwrap(); + let program = + parser::parse_wir_with_context(text, &catalog, &Locale::new("en-US"), &catalog).unwrap(); + for (locale, expected) in [ + ("en-US", "All Players(Team 1)"), + ("zh-CN", "所有玩家(队伍1)"), + ] { + let emitted = emitter::emit_wir(&program, &catalog, &Locale::new(locale)).unwrap(); + assert!(emitted.contains(expected), "{locale}:\n{emitted}"); + } +} + +#[test] +fn yes_no_settings_are_written_with_the_client_word() { + for (locale, text) in [ + ( + "en-US", + "settings {\n lobby {\n Allow Players Who Are In Queue: No\n Swap Teams After Match: Yes\n }\n}\n", + ), + ( + "zh-CN", + "设置 {\n 大厅 {\n 队列中的玩家可以加入: 否\n 比赛结束后转换队伍: 是\n }\n}\n", + ), + ] { + assert_eq!(round_trip(text, locale).trim_end(), text.trim_end()); + } +} + +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() +} + +fn tokens(text: &str) -> BTreeSet { + regex::Regex::new(r"[\p{L}\p{N}_.]+") + .unwrap() + .find_iter(text) + .map(|token| token.as_str().to_string()) + .collect() +} + +#[test] +fn overpy_generated_projects_keep_wrapper_counts_and_add_no_tokens() { + for case in common::cases() + .iter() + .filter(|case| matches!(case.id, "ai-pve-zh-CN" | "bastion-en-US")) + { + let (source, locale) = common::source(case); + let emitted = round_trip(&source, locale.as_str()); + assert_eq!( + wrapper_counts(&source), + wrapper_counts(&emitted), + "{} wrapper call counts changed", + case.id + ); + let added: Vec<_> = tokens(&emitted) + .difference(&tokens(&source)) + .cloned() + .collect(); + assert!( + added.is_empty(), + "{} emission added tokens: {added:?}", + case.id + ); + } +} + +#[test] +fn zh_cn_action_spellings_follow_the_pinned_overpy_emission() { + let spellings = [ + "消除所有图标;", + "消除所有地图文本;", + "消除所有效果;", + "消除所有HUD文本;", + "关闭游戏预设通告模式;", + "关闭游戏预设完成条件;", + "关闭游戏预设音乐模式;", + "关闭游戏预设计分模式;", + "开启游戏预设音乐模式;", + "比赛时间继续;", + ]; + let body: String = spellings + .iter() + .map(|line| format!(" {line}\n")) + .collect(); + let text = format!( + "规则 (\"t\") {{\n 事件 {{\n 持续 - 全局;\n }}\n 动作 {{\n{body} }}\n}}\n" + ); + let emitted = round_trip(&text, "zh-CN"); + for line in spellings { + assert!( + emitted.contains(&format!(" {line}\n")), + "`{line}` was not preserved:\n{emitted}" + ); + } +} diff --git a/docs/README.md b/docs/README.md index 6f2a2fe..bc7684f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,8 @@ Support prose is not a substitute for current executable tests. locale-aware ability resolution. - [Hero gameplay topology survey](gameplay-roster-survey.md): source material used by the gameplay domain. +- [Constant wrapper and settings-boolean forms](wrapper-forms.md): which + constants are written with a wrapper and which boolean word settings use. - [Canonical action layout](action-layout.md): validated WIR action-width and structured action expansion behavior. - [Test fixture source attribution](../crates/workshop-rs/tests/fixtures/README.md): diff --git a/docs/source-attribution/catalog.md b/docs/source-attribution/catalog.md index c8983a0..584a4fd 100644 --- a/docs/source-attribution/catalog.md +++ b/docs/source-attribution/catalog.md @@ -26,6 +26,7 @@ is embedded in the dataset and surfaced by `workshop-rs-cli version --json`. | `Create Dummy Bot` hero parameter union (`Hero|Array`) | Workshop behavior accepts a single Hero or Hero array and selects randomly when multiple heroes are provided; pinned OverPy 9.7.10 texture-tag setup emits `getAllHeroes()` into `Create Dummy Bot`. | | `Create Dummy Bot` slot coercion (`paramCoercions[2]`: `falseAsNumber`, `trueAsNumber`) | Pinned OverPy 9.7.10 `optimizeForSize` emission writes `False`/`True` for the `Create Dummy Bot` slot; observed by the opy-rs builtin probe (`catalog-missing-coercion`, wrightkit/opy-rs#370) and accepted for that parameter only under ADR-0014. | | `zh-CN` spellings of `break` (`中断`) and `isFiringSecondary` (`正在使用辅助武器`) | Pinned OverPy 9.7.10 zh-CN emission for Bastion, matching the locale corpus export (`actions.break`, `values..isFiringSecondaryFire`). `中断` is Break, not an Abort alias; the earlier `跳出循环` and `正在发射辅助攻击` spellings stay as parse aliases. | +| First-listed `zh-CN` spelling of ten actions (`destroyAllIcons`, `destroyAllInWorldText`, `destroyAllEffects`, `destroyAllHudText`, `disableBuiltinGameMode{Announcer,Completion,Music,Scoring}`, `enableBuiltinGameModeMusic`, `unpauseMatchTime`) | Pinned OverPy 9.7.10 zh-CN emission of the corresponding functions, per [ADR-0014](../adr/0014-validation-evidence-for-slot-acceptance.md); the previously emitted spelling stays a parse alias. See [wrapper forms](../wrapper-forms.md). | | Action/Value signature cross-check | Representative Workshop.codes article links remain recorded with the catalog entries; fetched snapshots and CI results are not generator or runtime inputs. | | Settings emission table (`src/settings/table.rs` and generated data files) | Hand-written fixture surface plus the reviewed `workshop-data` export at commit `d854bf01fc7bbf3b2169f67408c07a8da8989ad6`; generated entries, names, locale mappings, and source paths are committed together in the declared multi-locale projection, while pinned OverPy 9.7.10 output remains the behavioral check. | diff --git a/docs/wrapper-forms.md b/docs/wrapper-forms.md new file mode 100644 index 0000000..9bb8d6e --- /dev/null +++ b/docs/wrapper-forms.md @@ -0,0 +1,53 @@ +# Constant wrapper and settings-boolean forms + +Emission writes constants and boolean settings in the forms that pinned OverPy +9.7.10 writes, because the Workshop client imports those forms in production +projects. Acceptance of other spellings is unknown, and some are rejected. The +evidence class is "deliberate upstream emission rule" in +[ADR-0014](adr/0014-validation-evidence-for-slot-acceptance.md); a live-client +capture overrides it. + +The parser accepts both the wrapped and the bare spelling of every constant. +Emission never preserves the input spelling; it applies the rule below. + +## Constant wrappers + +The rule is keyed on the constant's domain, not on the parameter it fills, and +is the same for every emit path (`emit`, `convert`, action layout) and locale. + +| Domain | Written | Example (en-US / zh-CN) | +| --- | --- | --- | +| Hero | `Hero(Member)` | `Hero(Mercy)` / `英雄(天使)` | +| Button | `Button(Member)` | `Button(Reload)` / `按钮(装填)` | +| Color | `Color(Member)` | `Color(Red)` / `颜色(红色)` | +| Map | `Map(Member)` | `Map(Hanamura)` / `地图(花村)` | +| Team | bare member | `Team 1`, `All Teams` / `队伍1`, `所有队伍` | +| Every other enum domain | bare member | `Up` / `上` | + +Hero, Button, Color, and Map are wrapped in Hero-typed parameters, Array +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. + +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 (`颜色`, `英雄`, `按钮`, `地图`). + +## Boolean settings + +Settings declared `KeyKind::YesNo` take `Yes`/`No` (`是`/`否`); every other +boolean setting takes `On`/`Off`. Only `Allow Players Who Are In Queue` and +`Swap Teams After Match` are yes/no settings, as in the real-project fixtures +generated by OverPy. + +## Multi-spelling actions + +Where the catalog lists several zh-CN spellings for an action, the first is +emitted and the others are parse aliases. For the ten actions that pinned +OverPy 9.7.10 spells differently (`消除所有图标`, `消除所有地图文本`, +`消除所有效果`, `消除所有HUD文本`, `关闭游戏预设通告模式`, +`关闭游戏预设完成条件`, `关闭游戏预设音乐模式`, `关闭游戏预设计分模式`, +`开启游戏预设音乐模式`, `比赛时间继续`), the OverPy spelling is first. From f0e5de181cf1553efe79758dd2cba37f43a50e5e Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 16:57:18 +0800 Subject: [PATCH 2/4] fix(test): avoid format! collect in wrapper form helpers --- crates/workshop-rs/tests/wrapper_forms.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/workshop-rs/tests/wrapper_forms.rs b/crates/workshop-rs/tests/wrapper_forms.rs index 5fec8b5..9212609 100644 --- a/crates/workshop-rs/tests/wrapper_forms.rs +++ b/crates/workshop-rs/tests/wrapper_forms.rs @@ -20,7 +20,7 @@ fn round_trip(text: &str, locale: &str) -> String { fn assert_lines_preserved(actions: &[&str], locale: &str, event: &str, section: &str) { let body: String = actions .iter() - .map(|line| format!(" {line}\n")) + .map(|line| [" ", line, "\n"].concat()) .collect(); let text = format!( "{section} (\"t\") {{\n {event} {{\n {}\n }}\n actions {{\n{body} }}\n}}\n", @@ -170,7 +170,7 @@ fn zh_cn_action_spellings_follow_the_pinned_overpy_emission() { ]; let body: String = spellings .iter() - .map(|line| format!(" {line}\n")) + .map(|line| [" ", line, "\n"].concat()) .collect(); let text = format!( "规则 (\"t\") {{\n 事件 {{\n 持续 - 全局;\n }}\n 动作 {{\n{body} }}\n}}\n" From a9c43e83f243f5e5164b26a62c26305aee955925 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 17:01:52 +0800 Subject: [PATCH 3/4] test: exercise the OverPy-generated fixtures and emit Domina for Jinyu The fixture regression filtered on non-existent case ids and matched nothing. It now selects ai-pve and bastion and fails if either is missing. Exercising it showed the pinned OverPy spelling Domina was not the emitted hero alias. --- crates/workshop-rs/src/catalog/data/catalog.json | 6 +++--- crates/workshop-rs/tests/identity.rs | 2 +- crates/workshop-rs/tests/wrapper_forms.rs | 16 +++++++++++----- docs/wrapper-forms.md | 4 ++++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/workshop-rs/src/catalog/data/catalog.json b/crates/workshop-rs/src/catalog/data/catalog.json index a5c71ff..858ba86 100644 --- a/crates/workshop-rs/src/catalog/data/catalog.json +++ b/crates/workshop-rs/src/catalog/data/catalog.json @@ -7888,7 +7888,7 @@ ] } ], - "digest": "4a2702e63611573541c0880b942fed0cfec63190ef44f994739174e21d10e000", + "digest": "d9cca56119ddb28dbb9363ca1a2a22db2efb70c9bcd7ac738a7fd985099b7e9a", "enums": [ { "domain": "Impulse", @@ -14694,8 +14694,8 @@ { "aliases": { "en-US": [ - "Jinyu", - "Domina" + "Domina", + "Jinyu" ], "zh-CN": "金驭", "zh-TW": "多米娜" diff --git a/crates/workshop-rs/tests/identity.rs b/crates/workshop-rs/tests/identity.rs index 8e044ee..23c7466 100644 --- a/crates/workshop-rs/tests/identity.rs +++ b/crates/workshop-rs/tests/identity.rs @@ -12,7 +12,7 @@ use workshop_rs::catalog::{Catalog, Locale}; /// (`workshop-catalog-gen build`) recomputes it and the pin is updated /// deliberately together with the data. const PINNED_CATALOG_DIGEST: &str = - "4a2702e63611573541c0880b942fed0cfec63190ef44f994739174e21d10e000"; + "d9cca56119ddb28dbb9363ca1a2a22db2efb70c9bcd7ac738a7fd985099b7e9a"; #[test] fn committed_catalog_digest_is_pinned() { diff --git a/crates/workshop-rs/tests/wrapper_forms.rs b/crates/workshop-rs/tests/wrapper_forms.rs index 9212609..f3f18b2 100644 --- a/crates/workshop-rs/tests/wrapper_forms.rs +++ b/crates/workshop-rs/tests/wrapper_forms.rs @@ -120,20 +120,26 @@ fn wrapper_counts(text: &str) -> Vec { .collect() } +/// Tokens of everything after the leading settings block. Settings labels and +/// number formatting are outside the wrapper rule, and a leading `.` (variable +/// member access) is not a new token. fn tokens(text: &str) -> BTreeSet { + let body = text.split_once("\n}\n").map_or(text, |(_, rest)| rest); regex::Regex::new(r"[\p{L}\p{N}_.]+") .unwrap() - .find_iter(text) - .map(|token| token.as_str().to_string()) + .find_iter(body) + .map(|token| token.as_str().trim_start_matches('.').to_string()) .collect() } #[test] fn overpy_generated_projects_keep_wrapper_counts_and_add_no_tokens() { - for case in common::cases() + let cases: Vec<_> = common::cases() .iter() - .filter(|case| matches!(case.id, "ai-pve-zh-CN" | "bastion-en-US")) - { + .filter(|case| matches!(case.id, "ai-pve" | "bastion")) + .collect(); + assert_eq!(cases.len(), 2, "both OverPy-generated fixtures must run"); + for case in cases { let (source, locale) = common::source(case); let emitted = round_trip(&source, locale.as_str()); assert_eq!( diff --git a/docs/wrapper-forms.md b/docs/wrapper-forms.md index 9bb8d6e..5377140 100644 --- a/docs/wrapper-forms.md +++ b/docs/wrapper-forms.md @@ -51,3 +51,7 @@ OverPy 9.7.10 spells differently (`消除所有图标`, `消除所有地图文 `消除所有效果`, `消除所有HUD文本`, `关闭游戏预设通告模式`, `关闭游戏预设完成条件`, `关闭游戏预设音乐模式`, `关闭游戏预设计分模式`, `开启游戏预设音乐模式`, `比赛时间继续`), the OverPy spelling is first. + +The same rule applies to the Jinyu hero: pinned OverPy 9.7.10 and the Bastion +fixture spell it `Domina`, so `Domina` is emitted and `Jinyu` remains a parse +alias. From d75831b318830a4ce85560c6d4d482c87e1dce3d Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 17:22:31 +0800 Subject: [PATCH 4/4] fix(emitter): keep OverPy-generated fixtures token-identical Restores the whole-fixture no-added-token check. Settings numbers keep their shortest spelling, hero settings prefer the producer alias, and indexed player-variable actions split the player and name pair again. --- crates/workshop-rs/src/actions/emitter.rs | 18 ++++++++++++++++ crates/workshop-rs/src/core/format.rs | 9 ++++++++ crates/workshop-rs/src/output/emitter.rs | 1 - .../settings/data/hero_setting_aliases.json | 1 + crates/workshop-rs/src/settings/emitter.rs | 13 ++++++++++-- crates/workshop-rs/src/settings/schema.rs | 4 ++-- crates/workshop-rs/src/settings/table.rs | 21 +++++++++---------- crates/workshop-rs/tests/wrapper_forms.rs | 8 ++----- docs/wrapper-forms.md | 9 ++++++++ 9 files changed, 62 insertions(+), 22 deletions(-) diff --git a/crates/workshop-rs/src/actions/emitter.rs b/crates/workshop-rs/src/actions/emitter.rs index e7afdc4..3f0841a 100644 --- a/crates/workshop-rs/src/actions/emitter.rs +++ b/crates/workshop-rs/src/actions/emitter.rs @@ -322,6 +322,24 @@ impl EmitContext<'_> { if index > 0 { args_text.push_str(", "); } + // The frontend merges the leading `player, name` pair of + // the indexed player-variable actions into one + // player-variable value; split it back. + if index == 0 + && matches!( + name.as_str(), + "setPlayerVariableAtIndex" | "modifyPlayerVariableAtIndex" + ) + { + if let Some(wir::Value::PlayerVariable { player, variable }) = + self.program.values.get(*arg).map(|node| &node.value) + { + self.value(*player, &mut args_text)?; + args_text.push_str(", "); + args_text.push_str(self.player_name(*variable)?); + continue; + } + } let variable_position = match name.as_str() { "setGlobalVariableAtIndex" | "modifyGlobalVariableAtIndex" => { index == 0 diff --git a/crates/workshop-rs/src/core/format.rs b/crates/workshop-rs/src/core/format.rs index 91f12d9..8078fa4 100644 --- a/crates/workshop-rs/src/core/format.rs +++ b/crates/workshop-rs/src/core/format.rs @@ -36,6 +36,15 @@ pub fn format_number(value: f64) -> String { } } +/// Format a settings number: the shortest decimal that round-trips, so a +/// value read from a project is written back unchanged. +pub fn format_setting_number(value: f64) -> String { + if value.fract() == 0.0 && value.abs() < 1e15 { + return format_number(value); + } + format!("{value}") +} + /// Keep at most `max_digits` significant digits of a decimal string, /// truncating (not rounding) and expanding any exponent form. fn truncate_significant(text: &str, max_digits: usize) -> String { diff --git a/crates/workshop-rs/src/output/emitter.rs b/crates/workshop-rs/src/output/emitter.rs index 6e88b84..51736bb 100644 --- a/crates/workshop-rs/src/output/emitter.rs +++ b/crates/workshop-rs/src/output/emitter.rs @@ -12,7 +12,6 @@ pub(crate) use std::fmt::Write; pub(crate) use crate::catalog::{Catalog, Kind, Locale}; pub(crate) use crate::core::error::{Result, WorkshopError}; -pub(crate) use crate::core::format::format_number; pub(crate) use crate::settings::table::KeyKind; pub(crate) use crate::settings::{PathPart, table}; pub(crate) use crate::settings::{Settings as SettingsTree, SettingsNode}; diff --git a/crates/workshop-rs/src/settings/data/hero_setting_aliases.json b/crates/workshop-rs/src/settings/data/hero_setting_aliases.json index deee4d8..c43fb3f 100644 --- a/crates/workshop-rs/src/settings/data/hero_setting_aliases.json +++ b/crates/workshop-rs/src/settings/data/hero_setting_aliases.json @@ -2,6 +2,7 @@ { "hero": "roadhog", "key": "secondaryFireCooldown%", "locale": "en-US", "display": "secondaryFireCooldown%" }, { "hero": "roadhog", "key": "secondaryFireCooldown%", "locale": "zh-CN", "display": "secondaryFireCooldown%" }, { "hero": "sojourn", "key": "secondaryFireEnergyChargeRate%", "locale": "zh-CN", "display": "充能速度 充能射击" }, + { "hero": "roadhog", "key": "secondaryFireRechargeRate%", "locale": "zh-CN", "display": "secondaryFireRechargeRate%" }, { "hero": "roadhog", "key": "secondaryFireRechargeRate%", "locale": "zh-CN", "display": "呼吸器充能速度" }, { "hero": "hazard", "key": "secondaryFireRegen%", "locale": "zh-CN", "display": "尖刺护体资源恢复" }, { "hero": "hazard", "key": "secondaryFireCost%", "locale": "zh-CN", "display": "尖刺护体资源消耗" } diff --git a/crates/workshop-rs/src/settings/emitter.rs b/crates/workshop-rs/src/settings/emitter.rs index 9a146ef..1dcb947 100644 --- a/crates/workshop-rs/src/settings/emitter.rs +++ b/crates/workshop-rs/src/settings/emitter.rs @@ -236,12 +236,21 @@ impl EmitContext<'_> { self.line(level, &format!("{display_name}: {display}"))?; } (SettingsNode::Number { value, .. }, KeyKind::Number) => { - self.line(level, &format!("{display_name}: {}", format_number(*value)))?; + self.line( + level, + &format!( + "{display_name}: {}", + crate::format::format_setting_number(*value) + ), + )?; } (SettingsNode::Number { value, .. }, KeyKind::Percent) => { self.line( level, - &format!("{display_name}: {}%", format_number(*value)), + &format!( + "{display_name}: {}%", + crate::format::format_setting_number(*value) + ), )?; } (SettingsNode::Bool { value, .. }, KeyKind::Bool) => { diff --git a/crates/workshop-rs/src/settings/schema.rs b/crates/workshop-rs/src/settings/schema.rs index cac3ec1..05a4ac0 100644 --- a/crates/workshop-rs/src/settings/schema.rs +++ b/crates/workshop-rs/src/settings/schema.rs @@ -921,10 +921,10 @@ fn source_value_spelling( }) } (SettingValueDomain::Number(_), KeyKind::Number, SettingValue::Number(value)) => { - Ok(crate::format::format_number(value)) + Ok(crate::format::format_setting_number(value)) } (SettingValueDomain::Percent(_), KeyKind::Percent, SettingValue::Percent(value)) => { - Ok(format!("{}%", crate::format::format_number(value))) + Ok(format!("{}%", crate::format::format_setting_number(value))) } (SettingValueDomain::String, KeyKind::String, SettingValue::String(value)) => Ok(format!( "\"{}\"", diff --git a/crates/workshop-rs/src/settings/table.rs b/crates/workshop-rs/src/settings/table.rs index 058e6cd..689bf70 100644 --- a/crates/workshop-rs/src/settings/table.rs +++ b/crates/workshop-rs/src/settings/table.rs @@ -1077,20 +1077,19 @@ pub(crate) fn ability_slot_for_path(path: &[PathPart<'_>]) -> Option<&'static st /// Resolve a source-backed hero-specific setting label. pub(crate) fn hero_setting_name(hero: &str, key: &str, locale: &str) -> Option<&'static str> { - let generated = GENERATED_HERO_SETTING_NAMES + // A producer alias is the spelling pinned OverPy writes, so it is emitted + // in preference to the export label. + hero_setting_aliases() .iter() - .find(|entry| entry.hero == hero && entry.key == key) - .and_then(|entry| entry.localized(locale)); - generated + .find(|alias| { + alias.hero == hero && alias.key == key && alias.locale.eq_ignore_ascii_case(locale) + }) + .map(|alias| alias.display.as_str()) .or_else(|| { - hero_setting_aliases() + GENERATED_HERO_SETTING_NAMES .iter() - .find(|alias| { - alias.hero == hero - && alias.key == key - && alias.locale.eq_ignore_ascii_case(locale) - }) - .map(|alias| alias.display.as_str()) + .find(|entry| entry.hero == hero && entry.key == key) + .and_then(|entry| entry.localized(locale)) }) .or_else(|| { if locale.eq_ignore_ascii_case("en-US") { diff --git a/crates/workshop-rs/tests/wrapper_forms.rs b/crates/workshop-rs/tests/wrapper_forms.rs index f3f18b2..4b468f7 100644 --- a/crates/workshop-rs/tests/wrapper_forms.rs +++ b/crates/workshop-rs/tests/wrapper_forms.rs @@ -120,15 +120,11 @@ fn wrapper_counts(text: &str) -> Vec { .collect() } -/// Tokens of everything after the leading settings block. Settings labels and -/// number formatting are outside the wrapper rule, and a leading `.` (variable -/// member access) is not a new token. fn tokens(text: &str) -> BTreeSet { - let body = text.split_once("\n}\n").map_or(text, |(_, rest)| rest); regex::Regex::new(r"[\p{L}\p{N}_.]+") .unwrap() - .find_iter(body) - .map(|token| token.as_str().trim_start_matches('.').to_string()) + .find_iter(text) + .map(|token| token.as_str().to_string()) .collect() } diff --git a/docs/wrapper-forms.md b/docs/wrapper-forms.md index 5377140..fdab68f 100644 --- a/docs/wrapper-forms.md +++ b/docs/wrapper-forms.md @@ -55,3 +55,12 @@ OverPy 9.7.10 spells differently (`消除所有图标`, `消除所有地图文 The same rule applies to the Jinyu hero: pinned OverPy 9.7.10 and the Bastion fixture spell it `Domina`, so `Domina` is emitted and `Jinyu` remains a parse alias. + +## Other source-to-emit differences + +For the fixtures generated by OverPy, emission adds no token absent from the +input. Beyond the rules above this means: settings numbers keep their shortest +round-trip spelling, hero settings use the producer alias in +`hero_setting_aliases.json` before the export label (including the raw +`secondaryFireRechargeRate%` key that OverPy writes for Roadhog in zh-CN), and +the indexed player-variable actions keep their `player, name` argument pair.