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()