Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"contracts/registry",
"contracts/escrow",
"contracts/settlement",
"contracts/puzzle",
]

[workspace.dependencies]
Expand Down
11 changes: 11 additions & 0 deletions contracts/puzzle/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
21 changes: 21 additions & 0 deletions contracts/puzzle/locales/en.ftl
Original file line number Diff line number Diff line change
@@ -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 <code>

# 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!
21 changes: 21 additions & 0 deletions contracts/puzzle/locales/es.ftl
Original file line number Diff line number Diff line change
@@ -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 <code>

# 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!
21 changes: 21 additions & 0 deletions contracts/puzzle/locales/fr.ftl
Original file line number Diff line number Diff line change
@@ -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 <code>

# 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 !
109 changes: 109 additions & 0 deletions contracts/puzzle/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<FluentResource>,
fallback_bundle: FluentBundle<FluentResource>,
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 <code>` from command-line arguments.
pub fn parse_lang_flag<I>(args: I) -> Option<String>
where
I: Iterator<Item = String>,
{
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
}
34 changes: 34 additions & 0 deletions contracts/puzzle/src/main.rs
Original file line number Diff line number Diff line change
@@ -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));
}
79 changes: 79 additions & 0 deletions contracts/puzzle/src/test.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}