diff --git a/crates/workshop-rs/src/actions/emitter.rs b/crates/workshop-rs/src/actions/emitter.rs index e7afdc43..3f0841a1 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/actions/layout.rs b/crates/workshop-rs/src/actions/layout.rs index e61d6118..4581b152 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 ace2af9a..e8a3ff5d 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 527b9d59..858ba86e 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": "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/src/core/format.rs b/crates/workshop-rs/src/core/format.rs index 91f12d99..8078fa4a 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/convert.rs b/crates/workshop-rs/src/output/convert.rs index a6f1ce8a..93f0f467 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 3a91d875..51736bb1 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}; @@ -51,8 +50,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 +62,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 +72,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 +80,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 +105,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/hero_setting_aliases.json b/crates/workshop-rs/src/settings/data/hero_setting_aliases.json index deee4d85..c43fb3f9 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/data/locales.json b/crates/workshop-rs/src/settings/data/locales.json index 19c4f760..454ee75f 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 c2a222eb..fc1dc9ef 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 89e33d4c..1dcb9479 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) => { @@ -252,6 +261,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 ef13fc6d..2bfcc03f 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 dbf4c276..05a4ac0a 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 { @@ -918,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!( "\"{}\"", @@ -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 6f8a0257..689bf70a 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, @@ -1075,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/src/tests.rs b/crates/workshop-rs/src/tests.rs index 9df89613..48cf5ed6 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 826ce5b2..7be0759c 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 4a9df8a7..7555f5f7 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 445577df..23c74664 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"; + "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 new file mode 100644 index 00000000..4b468f7e --- /dev/null +++ b/crates/workshop-rs/tests/wrapper_forms.rs @@ -0,0 +1,187 @@ +//! 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| [" ", line, "\n"].concat()) + .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() { + let cases: Vec<_> = common::cases() + .iter() + .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!( + 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| [" ", line, "\n"].concat()) + .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 6f2a2fe5..bc7684f5 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 c8983a0f..584a4fd9 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 00000000..fdab68f1 --- /dev/null +++ b/docs/wrapper-forms.md @@ -0,0 +1,66 @@ +# 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. + +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.