From 4e24e2d9890a38101220fcfbb170d0246bc0d613 Mon Sep 17 00:00:00 2001 From: Xavier V Date: Tue, 15 Sep 2026 17:46:59 -0400 Subject: [PATCH] feat: add fill-in-the-blank cipher mode --- .gitignore | 37 ++++++++++++++- README.md | 7 ++- SoT.md | 1 + public/index.html | 1 + src-tauri/src/lib.rs | 106 +++++++++++++++++++++++++++++++++++++++---- src/main.rs | 81 +++++++++++++++++++++++++++++---- 6 files changed, 211 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..f558fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,36 @@ -/target +# Rust and Tauri build output +/target/ +/src-tauri/target/ + +# Operating-system metadata +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100/ +.Trashes/ +.fseventsd +.VolumeIcon.icns +Desktop.ini +Thumbs.db +ehthumbs.db +$RECYCLE.BIN/ + +# Editor and tool state +.idea/ +*.swp +*.swo +*.swn + +# Logs, temporary files, and local environment settings +*.log +*.tmp +*.temp +.env +.env.* +!.env.example + +# Files generated by the application when run from the repository +cipher_challenge.txt +cipher_hint.txt +cipher_solution.txt diff --git a/README.md b/README.md index fe45e8b..822c0f5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene ## 🌟 Key Features -* **8 Cipher Engines**: +* **9 Cipher Engines**: * **Elder Futhark Runes**: Custom handwritten runic character map. * **Caesar Shift**: Dynamic rotational shift ($1..25$). * **Atbash Cipher**: Reversed alphabet substitution. @@ -22,6 +22,7 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene * **Rail Fence (Zig-Zag)**: Multi-rail transposition matrix. * **Affine Cipher**: Mathematical linear function cipher ($E(x) = (ax + b) \pmod{26}$). * **A1Z26 Numeric**: Positional index substitution. + * **Fill in the Blank**: Assigns every letter a unique number from `01` to `26`, reveals a few mappings as clues, and preserves repeated-letter patterns. * **Local AI Integration & Fallback Support**: Connects to a local [Ollama](https://ollama.com/) instance (`llama3.2:1b`) to generate dynamic quotes. If Ollama is not installed or running, the engine automatically uses internal text prompts. * **Difficulty Scaling**: * **Easy**: Preserves original word boundaries, spaces, and punctuation. Generates detailed letter frequency and vowel count hints. @@ -31,6 +32,10 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene * `cipher_challenge.txt`: Formatted ciphertext ready to solve. * `cipher_hint.txt`: Cryptanalytic structural metrics and riddle clues. * `cipher_solution.txt`: Full original plaintext and decryption parameters/keys. + +### Fill in the Blank Mode + +This mode creates a numeric substitution puzzle. Each letter is assigned a unique number from `01` to `26`; the same letter always produces the same number, while different letters never share a number. Word boundaries remain visible in the challenge, and `cipher_hint.txt` reveals a small number of starter mappings based on the selected difficulty. The complete alphabet mapping is available only in `cipher_solution.txt`. * **Cross-Platform Support**: Native desktop GUI built for macOS and Windows. --- diff --git a/SoT.md b/SoT.md index 488215c..928729a 100644 --- a/SoT.md +++ b/SoT.md @@ -125,6 +125,7 @@ $$E(x) = (a \cdot x + b) \pmod{26}$$ 8. **A1Z26 Numeric**: Converts each letter to its 1-based numerical index separated by hyphens (e.g., $A=1, B=2, Z=26$). +9. **Fill in the Blank**: Builds a randomized one-to-one mapping from every letter in $A..Z$ to a unique number in $01..26$. The challenge displays those numbers while preserving word boundaries, `cipher_hint.txt` reveals a small set of known letter mappings, and `cipher_solution.txt` stores the complete mapping. ### B. Difficulty Formatting Pipeline diff --git a/public/index.html b/public/index.html index 9ba6f68..18e8281 100644 --- a/public/index.html +++ b/public/index.html @@ -58,6 +58,7 @@ + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c8ab69b..28d9e17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ enum CipherType { RailFence, Affine, A1Z26, + FillInTheBlank, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -52,6 +53,7 @@ fn generate_cipher_challenge( "Vigenere" => CipherType::Vigenere, "RailFence" => CipherType::RailFence, "Affine" => CipherType::Affine, + "FillInTheBlank" => CipherType::FillInTheBlank, _ => CipherType::A1Z26, }; @@ -202,7 +204,7 @@ fn get_custom_futhark_map() -> HashMap { fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, String) { let mut rng = rand::thread_rng(); - let (ciphertext, key_info) = match cipher { + let (ciphertext, key_info, custom_hint) = match cipher { CipherType::CustomFuthark => { let map = get_custom_futhark_map(); let encrypted = text @@ -210,7 +212,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, .chars() .map(|c| *map.get(&c).unwrap_or(&c)) .collect(); - (encrypted, "Elder Futhark Key Map".to_string()) + (encrypted, "Elder Futhark Key Map".to_string(), None) } CipherType::Caesar => { let shift = rng.gen_range(1..25) as u8; @@ -223,7 +225,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, format!("Caesar Shift = {}", shift)) + (encrypted, format!("Caesar Shift = {}", shift), None) } CipherType::Atbash => { let encrypted = text @@ -235,7 +237,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, "Atbash (Reversed Alphabet)".to_string()) + (encrypted, "Atbash (Reversed Alphabet)".to_string(), None) } CipherType::Substitution => { let mut alphabet: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); @@ -253,7 +255,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, }) .collect(); let key_str = map.iter().map(|(k, v)| format!("{}->{}", k, v)).collect::>().join(", "); - (encrypted, format!("Substitution Table: [{}]", key_str)) + (encrypted, format!("Substitution Table: [{}]", key_str), None) } CipherType::Vigenere => { let keys = vec!["ODIN", "THOR", "VALHALLA", "RUNE", "RUST", "CRYPT"]; @@ -272,7 +274,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, format!("Vigenere Passphrase: {}", key)) + (encrypted, format!("Vigenere Passphrase: {}", key), None) } CipherType::RailFence => { let rails = rng.gen_range(2..=4); @@ -288,7 +290,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } let encrypted = fence.into_iter().flatten().collect(); - (encrypted, format!("Rail Fence Depth = {} Rails", rails)) + (encrypted, format!("Rail Fence Depth = {} Rails", rails), None) } CipherType::Affine => { let a_options = vec![3, 5, 7, 9, 11, 15, 17, 19, 21, 23]; @@ -306,7 +308,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b)) + (encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b), None) } CipherType::A1Z26 => { let encrypted = text @@ -322,14 +324,73 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, .collect::() .trim_start_matches('-') .to_string(); - (encrypted, "A1Z26 Position Mapping".to_string()) + (encrypted, "A1Z26 Position Mapping".to_string(), None) + } + CipherType::FillInTheBlank => { + let (challenge, key, hint) = generate_fill_in_the_blank(text, diff, &mut rng); + (challenge, key, Some(hint)) } }; - let content_hint = generate_content_hints(text, diff); + let content_hint = custom_hint.unwrap_or_else(|| generate_content_hints(text, diff)); (ciphertext, key_info, content_hint) } +fn generate_fill_in_the_blank( + text: &str, + diff: Difficulty, + rng: &mut impl Rng, +) -> (String, String, String) { + let alphabet: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); + let mut numbers: Vec = (1..=26).collect(); + numbers.shuffle(rng); + let mapping: HashMap = alphabet.iter().copied().zip(numbers).collect(); + + let challenge = text + .chars() + .map(|character| { + if character.is_ascii_alphabetic() { + format!("{:02} ", mapping[&character.to_ascii_uppercase()]) + } else if character.is_whitespace() { + " ".to_string() + } else { + character.to_string() + } + }) + .collect::() + .trim_end() + .to_string(); + + let used_letters: Vec = alphabet + .iter() + .copied() + .filter(|letter| text.to_ascii_uppercase().contains(*letter)) + .collect(); + let clue_count = match diff { + Difficulty::Easy => 4, + Difficulty::Medium => 3, + Difficulty::Hard => 2, + } + .min(used_letters.len()); + let clues = used_letters + .iter() + .take(clue_count) + .map(|letter| format!("{} = {:02}", letter, mapping[letter])) + .collect::>(); + + let key = alphabet + .iter() + .map(|letter| format!("{}={:02}", letter, mapping[letter])) + .collect::>() + .join(", "); + let hint = format!( + "FILL-IN-THE-BLANK RULES:\n- Each number represents one letter.\n- Every letter uses a unique number from 01 to 26.\n- Repeated numbers represent repeated letters.\n\nKNOWN LETTER MAPPINGS:\n{}", + clues.join(", ") + ); + + (challenge, format!("Fill-in-the-Blank Number Key: {}", key), hint) +} + fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String { let cleaned: Vec = plaintext .chars() @@ -404,6 +465,31 @@ fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fill_in_the_blank_uses_unique_stable_numbers_and_clues() { + let (challenge, key, hint) = generate_fill_in_the_blank( + "ABBA CAB", + Difficulty::Easy, + &mut rand::thread_rng(), + ); + + let numbers: Vec<&str> = challenge + .split_whitespace() + .filter(|token| token.chars().all(|character| character.is_ascii_digit())) + .collect(); + assert_eq!(numbers.len(), 7); + assert_eq!(numbers[1], numbers[2]); + assert_eq!(numbers[0], numbers[3]); + assert!(key.contains("A=")); + assert!(key.contains("Z=")); + assert!(hint.contains("KNOWN LETTER MAPPINGS:")); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() diff --git a/src/main.rs b/src/main.rs index c7f4e45..a188c33 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ enum CipherType { RailFence, Affine, A1Z26, + FillInTheBlank, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -54,6 +55,7 @@ fn generate_cipher_challenge( "Vigenere" => CipherType::Vigenere, "RailFence" => CipherType::RailFence, "Affine" => CipherType::Affine, + "FillInTheBlank" => CipherType::FillInTheBlank, _ => CipherType::A1Z26, }; @@ -204,7 +206,7 @@ fn get_custom_futhark_map() -> HashMap { fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, String) { let mut rng = rand::thread_rng(); - let (ciphertext, key_info) = match cipher { + let (ciphertext, key_info, custom_hint) = match cipher { CipherType::CustomFuthark => { let map = get_custom_futhark_map(); let encrypted = text @@ -212,7 +214,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, .chars() .map(|c| *map.get(&c).unwrap_or(&c)) .collect(); - (encrypted, "Elder Futhark Key Map".to_string()) + (encrypted, "Elder Futhark Key Map".to_string(), None) } CipherType::Caesar => { let shift = rng.gen_range(1..25) as u8; @@ -225,7 +227,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, format!("Caesar Shift = {}", shift)) + (encrypted, format!("Caesar Shift = {}", shift), None) } CipherType::Atbash => { let encrypted = text @@ -237,7 +239,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, "Atbash (Reversed Alphabet)".to_string()) + (encrypted, "Atbash (Reversed Alphabet)".to_string(), None) } CipherType::Substitution => { let mut alphabet: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); @@ -255,7 +257,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, }) .collect(); let key_str = map.iter().map(|(k, v)| format!("{}->{}", k, v)).collect::>().join(", "); - (encrypted, format!("Substitution Table: [{}]", key_str)) + (encrypted, format!("Substitution Table: [{}]", key_str), None) } CipherType::Vigenere => { let keys = vec!["ODIN", "THOR", "VALHALLA", "RUNE", "RUST", "CRYPT"]; @@ -274,7 +276,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, format!("Vigenere Passphrase: {}", key)) + (encrypted, format!("Vigenere Passphrase: {}", key), None) } CipherType::RailFence => { let rails = rng.gen_range(2..=4); @@ -290,7 +292,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } let encrypted = fence.into_iter().flatten().collect(); - (encrypted, format!("Rail Fence Depth = {} Rails", rails)) + (encrypted, format!("Rail Fence Depth = {} Rails", rails), None) } CipherType::Affine => { let a_options = vec![3, 5, 7, 9, 11, 15, 17, 19, 21, 23]; @@ -308,7 +310,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, } else { c } }) .collect(); - (encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b)) + (encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b), None) } CipherType::A1Z26 => { let encrypted = text @@ -324,14 +326,73 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, .collect::() .trim_start_matches('-') .to_string(); - (encrypted, "A1Z26 Position Mapping".to_string()) + (encrypted, "A1Z26 Position Mapping".to_string(), None) + } + CipherType::FillInTheBlank => { + let (challenge, key, hint) = generate_fill_in_the_blank(text, diff, &mut rng); + (challenge, key, Some(hint)) } }; - let content_hint = generate_content_hints(text, diff); + let content_hint = custom_hint.unwrap_or_else(|| generate_content_hints(text, diff)); (ciphertext, key_info, content_hint) } +fn generate_fill_in_the_blank( + text: &str, + diff: Difficulty, + rng: &mut impl Rng, +) -> (String, String, String) { + let alphabet: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); + let mut numbers: Vec = (1..=26).collect(); + numbers.shuffle(rng); + let mapping: HashMap = alphabet.iter().copied().zip(numbers).collect(); + + let challenge = text + .chars() + .map(|character| { + if character.is_ascii_alphabetic() { + format!("{:02} ", mapping[&character.to_ascii_uppercase()]) + } else if character.is_whitespace() { + " ".to_string() + } else { + character.to_string() + } + }) + .collect::() + .trim_end() + .to_string(); + + let used_letters: Vec = alphabet + .iter() + .copied() + .filter(|letter| text.to_ascii_uppercase().contains(*letter)) + .collect(); + let clue_count = match diff { + Difficulty::Easy => 4, + Difficulty::Medium => 3, + Difficulty::Hard => 2, + } + .min(used_letters.len()); + let clues = used_letters + .iter() + .take(clue_count) + .map(|letter| format!("{} = {:02}", letter, mapping[letter])) + .collect::>(); + + let key = alphabet + .iter() + .map(|letter| format!("{}={:02}", letter, mapping[letter])) + .collect::>() + .join(", "); + let hint = format!( + "FILL-IN-THE-BLANK RULES:\n- Each number represents one letter.\n- Every letter uses a unique number from 01 to 26.\n- Repeated numbers represent repeated letters.\n\nKNOWN LETTER MAPPINGS:\n{}", + clues.join(", ") + ); + + (challenge, format!("Fill-in-the-Blank Number Key: {}", key), hint) +} + fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String { let cleaned: Vec = plaintext .chars()