From 6a9e5bf63fcc4b8ed607f63e6c2de864e9a018f2 Mon Sep 17 00:00:00 2001 From: User HP Date: Tue, 21 Jul 2026 02:36:43 -0700 Subject: [PATCH] feat(i18n): add internationalization support for puzzle text and UI strings --- CONTRIBUTING.md | 42 ++++++++++++ Cargo.toml | 1 + contracts/puzzle/Cargo.toml | 11 ++++ contracts/puzzle/locales/en.ftl | 21 ++++++ contracts/puzzle/locales/es.ftl | 21 ++++++ contracts/puzzle/locales/fr.ftl | 21 ++++++ contracts/puzzle/src/lib.rs | 109 ++++++++++++++++++++++++++++++++ contracts/puzzle/src/main.rs | 34 ++++++++++ contracts/puzzle/src/test.rs | 79 +++++++++++++++++++++++ 9 files changed, 339 insertions(+) create mode 100644 contracts/puzzle/Cargo.toml create mode 100644 contracts/puzzle/locales/en.ftl create mode 100644 contracts/puzzle/locales/es.ftl create mode 100644 contracts/puzzle/locales/fr.ftl create mode 100644 contracts/puzzle/src/lib.rs create mode 100644 contracts/puzzle/src/main.rs create mode 100644 contracts/puzzle/src/test.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6aa387..32ffe5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -190,6 +190,48 @@ A formal bug bounty program is coming. For now, critical vulnerabilities discove --- +## 🌐 Internationalization (i18n) Support + +We use [Mozilla Fluent](https://projectfluent.org/) for managing puzzle text, CLI prompts, hints, error messages, and TUI labels. Contributors can easily add support for new languages without modifying game logic. + +### How to Add a New Locale + +1. **Locate the locales directory**: + Navigate to `contracts/puzzle/locales/`. + +2. **Create a new `.ftl` file**: + Name your file using standard ISO 639-1 language codes (e.g., `de.ftl` for German, `ja.ftl` for Japanese, `pt.ftl` for Portuguese). + +3. **Copy and translate keys**: + Copy string definitions from `contracts/puzzle/locales/en.ftl` and translate values into your target language. Preserve variable parameters like `{$lang}` or `{$code}`. + + *Example (`de.ftl`)*: + ```fluent + welcome-title = Willkommen zur Mesh-Rätsel-Herausforderung! + puzzle-prompt = Lösen Sie das Rätsel: Finden Sie den versteckten Hash-Schlüssel. + ``` + +4. **Register the locale in `contracts/puzzle/src/lib.rs`**: + Add the locale code and resource file mapping to the `LOCALES` array: + ```rust + const LOCALES: &[(&str, &str)] = &[ + ("en", include_str!("../locales/en.ftl")), + ("fr", include_str!("../locales/fr.ftl")), + ("es", include_str!("../locales/es.ftl")), + ("de", include_str!("../locales/de.ftl")), // <-- Add your new locale here + ]; + ``` + +5. **Test your new locale**: + Run the CLI with your locale code: + ```bash + cargo run -p mesh-puzzle -- --lang de + ``` + +If any keys are missing in your translation file, the i18n system automatically falls back to English (`en`). + +--- + ## 💧 Support via Drips If you find this project valuable, consider funding it through [Drips](https://www.drips.network/app/projects/github/ThinknetCollective/mesh-contract). Wave 1 is active — your streaming contribution helps us ship audited contracts faster. diff --git a/Cargo.toml b/Cargo.toml index 2c705a1..31e4995 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/registry", "contracts/escrow", "contracts/settlement", + "contracts/puzzle", ] [workspace.dependencies] diff --git a/contracts/puzzle/Cargo.toml b/contracts/puzzle/Cargo.toml new file mode 100644 index 0000000..07b0e3d --- /dev/null +++ b/contracts/puzzle/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mesh-puzzle" +version = "0.1.0" +edition = "2021" +description = "Puzzle game CLI with internationalization (i18n) support" + +[dependencies] +fluent-bundle = "0.15" +unic-langid = "0.9" + +[dev-dependencies] diff --git a/contracts/puzzle/locales/en.ftl b/contracts/puzzle/locales/en.ftl new file mode 100644 index 0000000..bb2e54b --- /dev/null +++ b/contracts/puzzle/locales/en.ftl @@ -0,0 +1,21 @@ +# English translations for Mesh Puzzle CLI & UI + +# TUI Labels +welcome-title = Welcome to Mesh Puzzle Challenge! +tui-status-bar = Status: Ready | Locale: { $lang } +tui-menu-play = [P]lay Puzzle +tui-menu-quit = [Q]uit Game +tui-menu-select-lang = Select Language: --lang + +# Puzzle Prompts & Hints +puzzle-prompt = Solve the puzzle: Find the hidden hash key for the smart contract wave. +hint-label = Hint: Inspect the transaction output and verify signature bytes. +hint-puzzle-1 = Try submitting a valid Soroban authorization payload. + +# Error Messages +error-invalid-input = Error: Invalid input provided. Please enter a valid hex string or command. +error-puzzle-failed = Error: Puzzle verification failed. The provided solution is incorrect. +error-locale-missing = Warning: Locale '{$code}' not found. Falling back to English (en). + +# Success Messages +success-puzzle-solved = Success! Puzzle solved successfully. Smart contract wave unlocked! diff --git a/contracts/puzzle/locales/es.ftl b/contracts/puzzle/locales/es.ftl new file mode 100644 index 0000000..d4c7f2b --- /dev/null +++ b/contracts/puzzle/locales/es.ftl @@ -0,0 +1,21 @@ +# Spanish translations for Mesh Puzzle CLI & UI + +# TUI Labels +welcome-title = ¡Bienvenido al Desafío Mesh Puzzle! +tui-status-bar = Estado: Listo | Idioma: { $lang } +tui-menu-play = [J]ugar Puzzle +tui-menu-quit = [S]alir del juego +tui-menu-select-lang = Seleccionar idioma: --lang + +# Puzzle Prompts & Hints +puzzle-prompt = Resuelve el puzzle: Encuentra la clave hash oculta para la ola del contrato inteligente. +hint-label = Pista: Inspecciona la salida de la transacción y verifica los bytes de firma. +hint-puzzle-1 = Intenta enviar una carga útil de autorización Soroban válida. + +# Error Messages +error-invalid-input = Error: Entrada no válida. Por favor, introduce una cadena hexadecimal o comando válido. +error-puzzle-failed = Error: Falló la verificación del puzzle. La solución proporcionada es incorrecta. +error-locale-missing = Advertencia: Configuración regional '{$code}' no encontrada. Usando inglés (en) por defecto. + +# Success Messages +success-puzzle-solved = ¡Éxito! Puzzle resuelto correctamente. ¡Ola del contrato inteligente desbloqueada! diff --git a/contracts/puzzle/locales/fr.ftl b/contracts/puzzle/locales/fr.ftl new file mode 100644 index 0000000..7b00611 --- /dev/null +++ b/contracts/puzzle/locales/fr.ftl @@ -0,0 +1,21 @@ +# French translations for Mesh Puzzle CLI & UI + +# TUI Labels +welcome-title = Bienvenue au Défi Mesh Puzzle ! +tui-status-bar = État : Prêt | Langue : { $lang } +tui-menu-play = [J]ouer au Puzzle +tui-menu-quit = [Q]uitter le jeu +tui-menu-select-lang = Choisir la langue : --lang + +# Puzzle Prompts & Hints +puzzle-prompt = Résolvez le puzzle : Trouvez la clé de hachage cachée pour la vague du contrat intelligent. +hint-label = Indice : Inspectez la sortie de transaction et vérifiez les octets de signature. +hint-puzzle-1 = Essayez de soumettre une charge utile d'autorisation Soroban valide. + +# Error Messages +error-invalid-input = Erreur : Entrée non valide. Veuillez saisir une chaîne hexadécimale ou une commande valide. +error-puzzle-failed = Erreur : Échec de la vérification du puzzle. La solution fournie est incorrecte. +error-locale-missing = Avertissement : Paramètre régional '{$code}' non trouvé. Retour à l'anglais (en). + +# Success Messages +success-puzzle-solved = Succès ! Puzzle résolu avec succès. Vague de contrat intelligent déverrouillée ! diff --git a/contracts/puzzle/src/lib.rs b/contracts/puzzle/src/lib.rs new file mode 100644 index 0000000..c299f43 --- /dev/null +++ b/contracts/puzzle/src/lib.rs @@ -0,0 +1,109 @@ +use fluent_bundle::{FluentArgs, FluentResource, FluentBundle}; +use unic_langid::LanguageIdentifier; +use std::collections::HashMap; + +pub mod test; + +/// Embedded locale resources +const LOCALES: &[(&str, &str)] = &[ + ("en", include_str!("../locales/en.ftl")), + ("fr", include_str!("../locales/fr.ftl")), + ("es", include_str!("../locales/es.ftl")), +]; + +pub struct I18nManager { + active_locale: String, + active_bundle: FluentBundle, + fallback_bundle: FluentBundle, + fallback_triggered: bool, +} + +impl I18nManager { + /// Initializes i18n manager with requested locale code (e.g. "fr", "es", "en"). + /// If requested locale is missing or invalid, falls back to "en". + pub fn new(requested_locale: Option<&str>) -> Self { + let locale_map: HashMap<&str, &str> = LOCALES.iter().cloned().collect(); + + let fallback_lang: LanguageIdentifier = "en".parse().expect("Valid fallback langid"); + let mut fallback_bundle = FluentBundle::new(vec![fallback_lang]); + if let Some(en_src) = locale_map.get("en") { + let res = FluentResource::try_new(en_src.to_string()) + .expect("Failed to parse en.ftl resource"); + fallback_bundle.add_resource(res).expect("Failed to add en resource"); + } + + let requested = requested_locale.unwrap_or("en").to_lowercase(); + let target_code = if locale_map.contains_key(requested.as_str()) { + requested.as_str() + } else { + "en" + }; + + let fallback_triggered = target_code != requested.as_str(); + + let target_lang: LanguageIdentifier = target_code.parse().unwrap_or_else(|_| "en".parse().unwrap()); + let mut active_bundle = FluentBundle::new(vec![target_lang]); + + if let Some(src) = locale_map.get(target_code) { + if let Ok(res) = FluentResource::try_new(src.to_string()) { + let _ = active_bundle.add_resource(res); + } + } + + Self { + active_locale: target_code.to_string(), + active_bundle, + fallback_bundle, + fallback_triggered, + } + } + + /// Resolves localized string by key with optional arguments. + /// Falls back to English if key is missing in active locale. + pub fn get_message(&self, message_id: &str, args: Option<&FluentArgs>) -> String { + let mut errors = vec![]; + + // 1. Try active bundle + if let Some(msg) = self.active_bundle.get_message(message_id) { + if let Some(pattern) = msg.value() { + return self.active_bundle.format_pattern(pattern, args, &mut errors).to_string(); + } + } + + // 2. Try fallback (English) bundle + if let Some(msg) = self.fallback_bundle.get_message(message_id) { + if let Some(pattern) = msg.value() { + return self.fallback_bundle.format_pattern(pattern, args, &mut errors).to_string(); + } + } + + // 3. Fallback to key name if missing everywhere + message_id.to_string() + } + + /// Returns active locale code (e.g. "en", "fr", "es") + pub fn active_locale(&self) -> &str { + &self.active_locale + } + + /// Returns true if fallback to English was triggered due to missing locale + pub fn fallback_triggered(&self) -> bool { + self.fallback_triggered + } +} + +/// Helper to parse `--lang ` from command-line arguments. +pub fn parse_lang_flag(args: I) -> Option +where + I: Iterator, +{ + let mut iter = args.peekable(); + while let Some(arg) = iter.next() { + if arg == "--lang" { + return iter.next(); + } else if let Some(code) = arg.strip_prefix("--lang=") { + return Some(code.to_string()); + } + } + None +} diff --git a/contracts/puzzle/src/main.rs b/contracts/puzzle/src/main.rs new file mode 100644 index 0000000..1003cc0 --- /dev/null +++ b/contracts/puzzle/src/main.rs @@ -0,0 +1,34 @@ +use fluent_bundle::FluentArgs; +use mesh_puzzle::{parse_lang_flag, I18nManager}; + +fn main() { + let lang_arg = parse_lang_flag(std::env::args()); + let requested_lang = lang_arg.as_deref(); + + let i18n = I18nManager::new(requested_lang); + + if i18n.fallback_triggered() { + if let Some(requested) = requested_lang { + let mut args = FluentArgs::new(); + args.set("code", requested); + println!("{}", i18n.get_message("error-locale-missing", Some(&args))); + } + } + + // Display localized title + println!("=== {} ===", i18n.get_message("welcome-title", None)); + + // Display localized status bar + let mut status_args = FluentArgs::new(); + status_args.set("lang", i18n.active_locale()); + println!("{}", i18n.get_message("tui-status-bar", Some(&status_args))); + + // Display UI menu options + println!("\n{}", i18n.get_message("tui-menu-play", None)); + println!("{}", i18n.get_message("tui-menu-quit", None)); + println!("{}", i18n.get_message("tui-menu-select-lang", None)); + + // Display puzzle prompt and hints + println!("\nPrompt: {}", i18n.get_message("puzzle-prompt", None)); + println!("Hint: {}", i18n.get_message("hint-label", None)); +} diff --git a/contracts/puzzle/src/test.rs b/contracts/puzzle/src/test.rs new file mode 100644 index 0000000..9457536 --- /dev/null +++ b/contracts/puzzle/src/test.rs @@ -0,0 +1,79 @@ +#[cfg(test)] +mod tests { + use crate::{parse_lang_flag, I18nManager}; + use fluent_bundle::FluentArgs; + + #[test] + fn test_default_english_locale() { + let i18n = I18nManager::new(None); + assert_eq!(i18n.active_locale(), "en"); + assert!(!i18n.fallback_triggered()); + + let title = i18n.get_message("welcome-title", None); + assert_eq!(title, "Welcome to Mesh Puzzle Challenge!"); + } + + #[test] + fn test_french_locale_selection() { + let i18n = I18nManager::new(Some("fr")); + assert_eq!(i18n.active_locale(), "fr"); + assert!(!i18n.fallback_triggered()); + + let title = i18n.get_message("welcome-title", None); + assert_eq!(title, "Bienvenue au Défi Mesh Puzzle !"); + + let prompt = i18n.get_message("puzzle-prompt", None); + assert!(prompt.contains("Résolvez le puzzle")); + } + + #[test] + fn test_spanish_locale_selection() { + let i18n = I18nManager::new(Some("es")); + assert_eq!(i18n.active_locale(), "es"); + assert!(!i18n.fallback_triggered()); + + let title = i18n.get_message("welcome-title", None); + assert_eq!(title, "¡Bienvenido al Desafío Mesh Puzzle!"); + } + + #[test] + fn test_missing_locale_fallback_to_english() { + let i18n = I18nManager::new(Some("de")); // German not present + assert_eq!(i18n.active_locale(), "en"); + assert!(i18n.fallback_triggered()); + + let title = i18n.get_message("welcome-title", None); + assert_eq!(title, "Welcome to Mesh Puzzle Challenge!"); + } + + #[test] + fn test_missing_key_fallback() { + let i18n = I18nManager::new(Some("fr")); + let missing = i18n.get_message("non-existent-key", None); + assert_eq!(missing, "non-existent-key"); + } + + #[test] + fn test_parameter_formatting() { + let i18n = I18nManager::new(Some("en")); + let mut args = FluentArgs::new(); + args.set("lang", "en"); + let status = i18n.get_message("tui-status-bar", Some(&args)); + assert!(status.contains("Locale: en")); + } + + #[test] + fn test_cli_flag_parsing() { + let args = vec!["puzzle".to_string(), "--lang".to_string(), "fr".to_string()]; + let parsed = parse_lang_flag(args.into_iter()); + assert_eq!(parsed, Some("fr".to_string())); + + let args_eq = vec!["puzzle".to_string(), "--lang=es".to_string()]; + let parsed_eq = parse_lang_flag(args_eq.into_iter()); + assert_eq!(parsed_eq, Some("es".to_string())); + + let args_none = vec!["puzzle".to_string()]; + let parsed_none = parse_lang_flag(args_none.into_iter()); + assert_eq!(parsed_none, None); + } +}