Merge pull request #5 from ReVax-2007/feature/fill-in-the-blank

feat: add fill-in-the-blank cipher mode
This commit is contained in:
Xavier Voet
2026-09-16 14:01:41 -04:00
committed by GitHub
6 changed files with 211 additions and 22 deletions

37
.gitignore vendored
View File

@@ -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

View File

@@ -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.
---

1
SoT.md
View File

@@ -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

View File

@@ -58,6 +58,7 @@
<option value="RailFence">Rail Fence (Zig-Zag)</option>
<option value="Affine">Affine Cipher</option>
<option value="A1Z26">A1Z26 Numeric</option>
<option value="FillInTheBlank">Fill in the Blank (Number Mapping)</option>
</select>
</div>

View File

@@ -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<char, char> {
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<char> = "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::<Vec<String>>().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::<String>()
.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<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let mut numbers: Vec<u8> = (1..=26).collect();
numbers.shuffle(rng);
let mapping: HashMap<char, u8> = 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::<String>()
.trim_end()
.to_string();
let used_letters: Vec<char> = 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::<Vec<String>>();
let key = alphabet
.iter()
.map(|letter| format!("{}={:02}", letter, mapping[letter]))
.collect::<Vec<String>>()
.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<char> = 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()

View File

@@ -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<char, char> {
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<char> = "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::<Vec<String>>().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::<String>()
.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<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let mut numbers: Vec<u8> = (1..=26).collect();
numbers.shuffle(rng);
let mapping: HashMap<char, u8> = 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::<String>()
.trim_end()
.to_string();
let used_letters: Vec<char> = 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::<Vec<String>>();
let key = alphabet
.iter()
.map(|letter| format!("{}={:02}", letter, mapping[letter]))
.collect::<Vec<String>>()
.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<char> = plaintext
.chars()