2 Commits

Author SHA1 Message Date
84fd2da49c feat: add integrations and mobile-ready setup
Add bundled JSON quote fallback, responsive UI, Discord slash commands, Android setup documentation, and complete integration installation instructions. Set the Tauri application version to 1.1.0 for the release.
2026-09-16 17:03:27 -04:00
4e24e2d989 feat: add fill-in-the-blank cipher mode 2026-09-15 17:46:59 -04:00
12 changed files with 427 additions and 159 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,7 +22,8 @@ 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.
* **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.
* **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 selects a quote from `src-tauri/data/quotes.json`.
* **Difficulty Scaling**:
* **Easy**: Preserves original word boundaries, spaces, and punctuation. Generates detailed letter frequency and vowel count hints.
* **Medium**: Converts text to uppercase and strips all punctuation.
@@ -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.
---
@@ -39,28 +44,73 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
If you do not want to run Ollama locally or prefer using your own custom paragraphs, quotes, or text passages:
1. Open `src-tauri/src/lib.rs` in your text editor.
2. Locate the `fallback_quote` function:
```rust
fn fallback_quote(length_choice: usize) -> String {
let topics = ["VIKINGS", "KNOWLEDGE", "FORTUNE", "STRATEGY", "FREEDOM"];
let actions = [
"SAILED ACROSS THE UNKNOWN",
"DISCOVERED HIDDEN TRUTHS",
"FAVORED THE BRAVE MINDS",
"PROTECTED LIBERTY WITH SECRETS"
];
// ...
}
3. To hardcode a custom text passage or quote directly, modify the function to return your desired text string:
```rust
fn fallback_quote(_length_choice: usize) -> String {
"YOUR CUSTOM PARAGRAPH OR PHRASE GOES HERE".to_string()
}
1. Edit `src-tauri/data/quotes.json` and add plain JSON strings to the array.
2. Keep the file as valid JSON. Quotes are selected by word count when possible, then a random entry is used as a final fallback.
3. Rebuild or run `cargo tauri dev` to apply the updated text pool.
## Android Packaging
Tauri v2 supports Android from the same project. Install Android Studio, the Android SDK, and the Rust Android targets, then run:
```bash
cargo tauri android init
cargo tauri android dev
cargo tauri android build
```
The generated Android project lives under `src-tauri/gen/android` and should be reviewed before committing. The responsive frontend and bundled quote library work without a desktop-only dependency; Ollama remains optional and is normally unavailable on a phone.
4. Rebuild or run `cargo tauri dev` to apply your custom text pool.
## Discord Slash Commands Setup
The opt-in starter bot lives in `integrations/discord-bot`. It supports `/cipher`, `/hint`, and `/solution`. Challenges are stored in memory per guild and user; hints and solutions are sent as ephemeral replies.
### 1. Create the Discord application
1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and select **New Application**.
2. Open **General Information** and copy the **Application ID**. This is `DISCORD_CLIENT_ID`.
3. Open **Bot**, select **Reset Token**, and copy the token once. This is `DISCORD_TOKEN`. Treat it like a password and never commit it.
4. Under **OAuth2 > URL Generator**, select the `bot` and `applications.commands` scopes.
5. Select the `Send Messages` bot permission, open the generated URL, and invite the bot to a test server.
### 2. Install and configure the bot
From the repository root:
```bash
cd integrations/discord-bot
npm install
```
Set the credentials in the same terminal session. macOS/Linux:
```bash
export DISCORD_TOKEN="paste-your-bot-token-here"
export DISCORD_CLIENT_ID="paste-your-application-id-here"
```
PowerShell:
```powershell
$env:DISCORD_TOKEN = "paste-your-bot-token-here"
$env:DISCORD_CLIENT_ID = "paste-your-application-id-here"
```
### 3. Register and run the commands
```bash
npm run register
npm start
```
The registration command installs the commands globally for the application. Discord can take up to an hour to propagate global commands. Keep `npm start` running while testing, or run it with a process manager for a hosted deployment.
Available commands:
* `/cipher` creates a Caesar or Atbash challenge. Use the optional `type` argument to choose the cipher.
* `/hint` returns a private hint for your current challenge.
* `/solution` returns the plaintext and key privately.
The bot uses the bundled `src-tauri/data/quotes.json` library. It does not call Ollama or persist challenges after restart. Add a database before running multiple bot instances or requiring durable challenge history.
---
@@ -105,6 +155,7 @@ cipher_game/
* [Rust](https://www.rust-lang.org/tools/install) (1.75 or newer)
* `cargo-tauri` CLI (Install via `cargo install tauri-cli --version "^2.0"`)
* *(Optional)* [Ollama](https://ollama.com/) running locally on port `11434` with `llama3.2:1b` installed (`ollama pull llama3.2:1b`).
* *(Optional, Discord bot only)* [Node.js](https://nodejs.org/) 20 or newer and npm.
---

View File

@@ -18,3 +18,30 @@ This document outlines proposed integrations with external services, tools, and
## 4. Hardware & Peripherals
* **Custom Printed Output (Thermal Printers)**: Direct USB/Bluetooth printer integration to print cryptographic escape room physical receipts/slips.
## 5. Other OSes
* **Android Packaging**: Tauri Android support is ready to initialize with `cargo tauri android init` and build with `cargo tauri android build`. The responsive frontend and bundled quote fallback keep the mobile app useful without Ollama.
## 6. Discord Slash Commands
* **Discord bot starter**: `integrations/discord-bot` provides `/cipher`, `/hint`, and `/solution`. It uses the bundled quote library, keeps challenge state per user, and makes hints and solutions ephemeral.
* **Future bot engines**: Add the remaining Cipher Master algorithms behind the same command contract, then move challenge persistence to a small database if the bot is deployed across multiple instances.
### Discord Setup Quick Start
1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
2. Copy the Application ID from **General Information** and reset/copy the bot token from **Bot**.
3. In **OAuth2 > URL Generator**, select `bot` and `applications.commands`, plus the `Send Messages` permission, then invite the bot to a test server.
4. Run from the repository root:
```bash
cd integrations/discord-bot
npm install
export DISCORD_TOKEN="your-bot-token"
export DISCORD_CLIENT_ID="your-application-id"
npm run register
npm start
```
5. Test `/cipher`, then use `/hint` or `/solution`. Never commit `DISCORD_TOKEN`; global command registration can take up to an hour to appear.
## 7. Integration Principles
* Keep integrations opt-in and credentials in environment variables.
* Keep solutions private by default.
* Reuse the bundled quote fallback whenever an external AI provider is unavailable.

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

@@ -0,0 +1,36 @@
import { Client, GatewayIntentBits } from "discord.js";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const quotes = JSON.parse(readFileSync(resolve(root, "src-tauri/data/quotes.json"), "utf8"));
const challenges = new Map();
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
function encrypt(text, type) {
if (type === "atbash") return text.replace(/[A-Z]/g, letter => String.fromCharCode(90 - (letter.charCodeAt(0) - 65)));
const shift = Math.floor(Math.random() * 25) + 1;
return { text: text.replace(/[A-Z]/g, letter => String.fromCharCode(65 + (letter.charCodeAt(0) - 65 + shift) % 26)), key: `Caesar shift ${shift}` };
}
client.once("ready", () => console.log(`Cipher Master connected as ${client.user.tag}`));
client.on("interactionCreate", async interaction => {
if (!interaction.isChatInputCommand()) return;
const id = `${interaction.guildId}:${interaction.user.id}`;
if (interaction.commandName === "cipher") {
const type = interaction.options.getString("type") ?? "caesar";
const plaintext = quotes[Math.floor(Math.random() * quotes.length)].toUpperCase();
const encrypted = encrypt(plaintext, type);
const challenge = typeof encrypted === "string" ? { text: encrypted, key: "Atbash: reversed alphabet" } : encrypted;
challenges.set(id, { ...challenge, plaintext, type });
await interaction.reply({ content: `**${type.toUpperCase()} CHALLENGE**\n\n\`${challenge.text}\``, ephemeral: false });
} else if (interaction.commandName === "hint" || interaction.commandName === "solution") {
const challenge = challenges.get(id);
if (!challenge) return interaction.reply({ content: "Create a challenge first with `/cipher`.", ephemeral: true });
const content = interaction.commandName === "hint" ? `Cipher: **${challenge.type}**. ${challenge.type === "caesar" ? "Try counting the alphabet shift." : "Map each letter to its mirror in the alphabet."}` : `Plaintext: **${challenge.plaintext}**\nKey: **${challenge.key}**`;
await interaction.reply({ content, ephemeral: true });
}
});
client.login(process.env.DISCORD_TOKEN);

View File

@@ -0,0 +1,7 @@
{
"name": "cipher-master-discord-bot",
"private": true,
"type": "module",
"scripts": { "start": "node index.js", "register": "node register-commands.js" },
"dependencies": { "discord.js": "^14.16.3" }
}

View File

@@ -0,0 +1,11 @@
import { REST, Routes, SlashCommandBuilder } from "discord.js";
const commands = [
new SlashCommandBuilder().setName("cipher").setDescription("Create a Cipher Master challenge").addStringOption(option => option.setName("type").setDescription("Cipher to use").setRequired(false).addChoices({ name: "Caesar", value: "caesar" }, { name: "Atbash", value: "atbash" })),
new SlashCommandBuilder().setName("hint").setDescription("Get a private hint for your current challenge"),
new SlashCommandBuilder().setName("solution").setDescription("Get the private solution for your current challenge")
].map(command => command.toJSON());
const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN);
await rest.put(Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { body: commands });
console.log("Registered /cipher, /hint, and /solution.");

View File

@@ -2,119 +2,49 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cipher Master</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
padding: 2rem;
margin: 0;
}
h1 { color: #89b4fa; margin-top: 0; }
.form-group { margin-bottom: 1.2rem; }
label { display: block; margin-bottom: 0.4rem; color: #a6adc8; }
select, button {
width: 100%;
padding: 0.6rem;
border-radius: 6px;
border: 1px solid #45475a;
background: #313244;
color: #cdd6f4;
font-size: 1rem;
}
button {
background: #89b4fa;
color: #11111b;
font-weight: bold;
cursor: pointer;
margin-top: 1rem;
border: none;
}
button:hover { background: #b4befe; }
#output {
margin-top: 1.5rem;
padding: 1rem;
background: #181825;
border-radius: 6px;
white-space: pre-wrap;
font-family: monospace;
max-height: 200px;
overflow-y: auto;
}
:root { --ink: #18222d; --muted: #6a7783; --panel: #fffdf8; --line: #ded7c9; --copper: #b75d3b; --teal: #176b6b; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; color: var(--ink); font-family: Georgia, "Times New Roman", serif; background: radial-gradient(circle at 90% 5%, #f0c9a5 0, transparent 28%), linear-gradient(135deg, #f7f3ea, #dfe9e4); }
main { width: min(1050px, 100%); margin: auto; padding: clamp(1.25rem, 4vw, 4rem); }
header { display: flex; justify-content: space-between; gap: 1rem; align-items: end; margin-bottom: 2rem; }
.eyebrow { color: var(--copper); font: 700 .75rem/1.2 system-ui, sans-serif; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: .35rem 0 0; font-size: clamp(2.1rem, 6vw, 4.6rem); line-height: .95; }
.mark { color: var(--teal); font-size: 2.5rem; }
.layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, .8fr); gap: 1.25rem; }
.panel { background: color-mix(in srgb, var(--panel) 92%, transparent); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 18px 45px #293b3214; padding: clamp(1.1rem, 3vw, 2rem); }
.panel h2 { margin: 0 0 1.35rem; font-size: 1.35rem; }
.fields { display: grid; gap: 1rem; }
label { display: grid; gap: .42rem; color: var(--muted); font: 700 .75rem system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
select, button { width: 100%; min-height: 48px; border: 1px solid var(--line); border-radius: 5px; padding: .75rem .85rem; font: 1rem system-ui, sans-serif; }
select { color: var(--ink); background: #fff; }
button { margin-top: 1.3rem; border: 0; color: #fff; background: var(--teal); font-weight: 700; cursor: pointer; transition: transform .2s, background .2s; }
button:hover { background: #0e5252; transform: translateY(-2px); }
button:disabled { cursor: wait; opacity: .65; transform: none; }
.side { display: grid; align-content: start; gap: 1rem; }
.status { color: var(--teal); font: 700 .78rem system-ui, sans-serif; text-transform: uppercase; letter-spacing: .08em; }
#output { min-height: 235px; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #f9f4e9; background: #18222d; border-radius: 5px; font: .85rem/1.55 ui-monospace, SFMono-Regular, monospace; }
.note { color: var(--muted); font: .92rem/1.5 system-ui, sans-serif; }
@media (max-width: 720px) { header { align-items: start; } .layout { grid-template-columns: 1fr; } .mark { font-size: 1.8rem; } }
</style>
</head>
<body>
<h1>ᛏ Cipher Master Engine</h1>
<div class="form-group">
<label for="cipher">Cipher Type:</label>
<select id="cipher">
<option value="CustomFuthark">Elder Futhark Runes</option>
<option value="Caesar">Caesar Shift</option>
<option value="Atbash">Atbash Cipher</option>
<option value="Substitution">Monoalphabetic Substitution</option>
<option value="Vigenere">Vigenère Cipher</option>
<option value="RailFence">Rail Fence (Zig-Zag)</option>
<option value="Affine">Affine Cipher</option>
<option value="A1Z26">A1Z26 Numeric</option>
</select>
</div>
<div class="form-group">
<label for="difficulty">Difficulty:</label>
<select id="difficulty">
<option value="Easy">Easy (Preserves spaces & punctuation)</option>
<option value="Medium">Medium (Uppercase, no punctuation)</option>
<option value="Hard">Hard (Grouped 5-character blocks)</option>
</select>
</div>
<div class="form-group">
<label for="length">Text Length:</label>
<select id="length">
<option value="1">Short (~5-8 words)</option>
<option value="2">Medium (~15-20 words)</option>
<option value="3">Long (~30+ words)</option>
</select>
</div>
<button onclick="generateCipher()">Generate Challenge Desktop Files</button>
<div id="output">Outputs will appear here...</div>
<main>
<header><div><div class="eyebrow">A field kit for curious minds</div><h1><span class="mark">ᛏ</span> Cipher Master</h1></div><div class="status" id="status">Ready to encrypt</div></header>
<section class="layout">
<div class="panel"><h2>Compose a challenge</h2><div class="fields">
<label for="cipher">Cipher<select id="cipher"><option value="CustomFuthark">Elder Futhark Runes</option><option value="Caesar">Caesar Shift</option><option value="Atbash">Atbash Cipher</option><option value="Substitution">Monoalphabetic Substitution</option><option value="Vigenere">Vigenere Cipher</option><option value="RailFence">Rail Fence</option><option value="Affine">Affine Cipher</option><option value="A1Z26">A1Z26 Numeric</option><option value="FillInTheBlank">Fill in the Blank</option></select></label>
<label for="difficulty">Difficulty<select id="difficulty"><option value="Easy">Easy - spaces and punctuation</option><option value="Medium">Medium - uppercase text</option><option value="Hard">Hard - five character blocks</option></select></label>
<label for="length">Quote length<select id="length"><option value="1">Short</option><option value="2">Medium</option><option value="3">Long</option></select></label>
</div><button id="generate" onclick="generateCipher()">Generate challenge</button></div>
<aside class="side"><div class="panel"><h2>Challenge output</h2><pre id="output">Your encrypted challenge and Desktop file paths will appear here.</pre></div><p class="note">Ollama is optional. When no model is available, Cipher Master selects a quote from its bundled local library.</p></aside>
</section>
</main>
<script>
function getInvoke() {
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
return window.__TAURI__.core.invoke;
} else if (window.__TAURI__ && window.__TAURI__.invoke) {
return window.__TAURI__.invoke;
} else if (window.__TAURI_INTERNALS__ && window.__TAURI_INTERNALS__.invoke) {
return window.__TAURI_INTERNALS__.invoke;
}
throw new Error("Tauri IPC bindings not found.");
}
async function generateCipher() {
const cipher = document.getElementById("cipher").value;
const difficulty = document.getElementById("difficulty").value;
const lengthChoice = parseInt(document.getElementById("length").value);
const output = document.getElementById("output");
output.innerText = "Querying local AI & generating cipher files...";
try {
const invokeFn = getInvoke();
const response = await invokeFn("generate_cipher_challenge", {
cipher_type: cipher,
difficulty: difficulty,
length_choice: lengthChoice
});
output.innerText = response;
} catch (err) {
output.innerText = "Error: " + (err.message || err);
}
}
function getInvoke() { const tauri = window.__TAURI__; if (tauri?.core?.invoke) return tauri.core.invoke; if (tauri?.invoke) return tauri.invoke; if (window.__TAURI_INTERNALS__?.invoke) return window.__TAURI_INTERNALS__.invoke; throw new Error("Tauri IPC bindings not found."); }
async function generateCipher() { const button = document.getElementById("generate"); const output = document.getElementById("output"); const status = document.getElementById("status"); button.disabled = true; status.textContent = "Forging challenge..."; output.textContent = "Selecting quote and encrypting..."; try { output.textContent = await getInvoke()("generate_cipher_challenge", { cipher_type: cipher.value, difficulty: difficulty.value, length_choice: Number(length.value) }); status.textContent = "Challenge ready"; } catch (error) { output.textContent = "Error: " + (error.message || error); status.textContent = "Generation failed"; } finally { button.disabled = false; } }
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
[
"Courage begins where certainty ends.",
"The patient mind finds patterns in the noise.",
"A clear question can unlock a hidden door.",
"Wisdom grows when curiosity survives the first mistake.",
"The map is useful, but the journey teaches the terrain.",
"Small clues become powerful when connected with care.",
"The strongest key is often the question nobody thought to ask.",
"A secret loses its power when a brave mind learns how to read it.",
"Every complex puzzle becomes smaller when its rules are made visible.",
"Knowledge is not a locked room; it is a path made by patient footsteps."
]

View File

@@ -1,4 +1,5 @@
use rand::seq::SliceRandom;
use rand::seq::IteratorRandom;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@@ -17,6 +18,7 @@ enum CipherType {
RailFence,
Affine,
A1Z26,
FillInTheBlank,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
@@ -38,6 +40,11 @@ struct OllamaResponse {
response: String,
}
fn bundled_quotes() -> Vec<String> {
serde_json::from_str(include_str!("../data/quotes.json"))
.expect("bundled quote data must be valid JSON")
}
#[tauri::command(rename_all = "snake_case")]
fn generate_cipher_challenge(
cipher_type: String,
@@ -52,6 +59,7 @@ fn generate_cipher_challenge(
"Vigenere" => CipherType::Vigenere,
"RailFence" => CipherType::RailFence,
"Affine" => CipherType::Affine,
"FillInTheBlank" => CipherType::FillInTheBlank,
_ => CipherType::A1Z26,
};
@@ -141,16 +149,21 @@ fn get_ai_quote(length_choice: usize) -> String {
}
fn fallback_quote(length_choice: usize) -> String {
let topics = ["VIKINGS", "KNOWLEDGE", "FORTUNE", "STRATEGY", "FREEDOM"];
let actions = ["SAILED ACROSS THE UNKNOWN", "DISCOVERED HIDDEN TRUTHS", "FAVORED THE BRAVE MINDS", "PROTECTED LIBERTY WITH SECRETS"];
let quotes = bundled_quotes();
let target_range = match length_choice {
1 => 5..=10,
2 => 11..=20,
_ => 21..=40,
};
let mut rng = rand::thread_rng();
let base = format!("{} {}", topics.choose(&mut rng).unwrap(), actions.choose(&mut rng).unwrap());
if length_choice > 1 {
format!("{} AND UNLOCKED THE SECRETS OF THE ANCIENT RUNES", base)
} else {
base
}
quotes
.iter()
.filter(|quote| target_range.contains(&quote.split_whitespace().count()))
.choose(&mut rng)
.or_else(|| quotes.choose(&mut rng))
.expect("quote fallback data must not be empty")
.to_uppercase()
}
fn get_desktop_path() -> PathBuf {
@@ -202,7 +215,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 +223,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 +236,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 +248,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 +266,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 +285,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 +301,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 +319,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 +335,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 +476,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

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cipher Master",
"version": "1.0.0",
"version": "1.1.0",
"identifier": "com.xvoet.ciphermaster",
"build": {
"frontendDist": "../public"

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