9 Commits

Author SHA1 Message Date
Xavier Voet
7f81eb6421 Merge pull request #7 from ReVax-2007/copilot/fix-android-apk-and-aab-job
Fix Android release job by disabling deprecated default SDK package install
2026-09-16 17:32:43 -04:00
copilot-swe-agent[bot]
a2a8e0199a Fix Android workflow SDK setup package selection
Co-authored-by: ReVax-2007 <152216732+ReVax-2007@users.noreply.github.com>
2026-09-16 21:28:06 +00:00
copilot-swe-agent[bot]
ca00312695 Initial plan 2026-09-16 21:24:42 +00:00
Xavier Voet
239dd0ad35 Merge pull request #6 from ReVax-2007/feature/new-integrations
## Summary

Adds the new integration and mobile-readiness features for Cipher Master.

## Changes

- Added bundled JSON quote fallback at `src-tauri/data/quotes.json`
- Updated Rust quote generation to use the fallback library when Ollama is unavailable
- Refreshed the responsive frontend UI
- Added Discord slash-command starter integration
- Added `/cipher`, `/hint`, and `/solution`
- Made Discord hints and solutions ephemeral
- Added Discord Developer Portal setup documentation
- Added Android packaging instructions
- Updated Tauri application version to `1.1.0`
- Updated README and integration documentation

## Discord Setup

The Discord integration includes:

- `integrations/discord-bot/index.js`
- `integrations/discord-bot/register-commands.js`
- `integrations/discord-bot/package.json`

Supported commands:

- `/cipher`
- `/hint`
- `/solution`

## Release

- Release tag: `v1.1.0`
- Feature branch: `feature/new-integrations`

## Validation

- Discord JavaScript syntax validated
- Quote JSON validated
- Rust diagnostics checked
- Committed diff passes `git diff --check`

## Android Note

The documentation includes the Tauri Android initialization and build commands. The current release workflow still produces desktop artifacts only. Android project initialization and Android CI packaging should be added separately before publishing an Android binary.
2026-09-16 17:16:40 -04:00
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
Xavier Voet
7cddbcedf2 Merge pull request #5 from ReVax-2007/feature/fill-in-the-blank
feat: add fill-in-the-blank cipher mode
2026-09-16 14:01:41 -04:00
4e24e2d989 feat: add fill-in-the-blank cipher mode 2026-09-15 17:46:59 -04:00
Xavier Voet
98917ccbf9 Merge pull request #4 from ReVax-2007/Fix--release-Build
fixed Release.
2026-09-14 11:38:34 -04:00
5f91eb3542 fixed Release. 2026-09-14 11:38:08 -04:00
14 changed files with 2124 additions and 166 deletions

View File

@@ -39,14 +39,70 @@ jobs:
patchelf patchelf
- name: Build Tauri App - name: Build Tauri App
uses: tauri-apps/tauri-action@v1 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tagName: v__VERSION__ projectPath: src-tauri
releaseName: 'Cipher Master v__VERSION__' tagName: ${{ github.ref_name }}
releaseName: 'Cipher Master ${{ github.ref_name }}'
releaseBody: 'Download the compiled standalone application binaries below.' releaseBody: 'Download the compiled standalone application binaries below.'
releaseDraft: true releaseDraft: true
prerelease: false prerelease: false
includeRelease: true
includeDebug: false android:
name: Android APK and AAB
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Install Android SDK packages
run: sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" "ndk;27.2.12479018"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android, armv7-linux-androideabi, i686-linux-android, x86_64-linux-android
- name: Install Tauri CLI
run: cargo install tauri-cli --version '^2.0' --locked
- name: Initialize Android project
working-directory: src-tauri
run: cargo tauri android init
- name: Build Android packages
working-directory: src-tauri
run: cargo tauri android build --apk --aab
- name: Upload Android packages
uses: actions/upload-artifact@v4
with:
name: cipher-master-android-${{ github.ref_name }}
path: |
src-tauri/gen/android/app/build/outputs/apk/**/*.apk
src-tauri/gen/android/app/build/outputs/bundle/**/*.aab
if-no-files-found: error
- name: Attach Android packages to GitHub release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
src-tauri/gen/android/app/build/outputs/apk/**/*.apk
src-tauri/gen/android/app/build/outputs/bundle/**/*.aab
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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

1638
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
## 🌟 Key Features ## 🌟 Key Features
* **8 Cipher Engines**: * **9 Cipher Engines**:
* **Elder Futhark Runes**: Custom handwritten runic character map. * **Elder Futhark Runes**: Custom handwritten runic character map.
* **Caesar Shift**: Dynamic rotational shift ($1..25$). * **Caesar Shift**: Dynamic rotational shift ($1..25$).
* **Atbash Cipher**: Reversed alphabet substitution. * **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. * **Rail Fence (Zig-Zag)**: Multi-rail transposition matrix.
* **Affine Cipher**: Mathematical linear function cipher ($E(x) = (ax + b) \pmod{26}$). * **Affine Cipher**: Mathematical linear function cipher ($E(x) = (ax + b) \pmod{26}$).
* **A1Z26 Numeric**: Positional index substitution. * **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**: * **Difficulty Scaling**:
* **Easy**: Preserves original word boundaries, spaces, and punctuation. Generates detailed letter frequency and vowel count hints. * **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. * **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_challenge.txt`: Formatted ciphertext ready to solve.
* `cipher_hint.txt`: Cryptanalytic structural metrics and riddle clues. * `cipher_hint.txt`: Cryptanalytic structural metrics and riddle clues.
* `cipher_solution.txt`: Full original plaintext and decryption parameters/keys. * `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. * **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: 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. 1. Edit `src-tauri/data/quotes.json` and add plain JSON strings to the array.
2. Locate the `fallback_quote` function: 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.
```rust 3. Rebuild or run `cargo tauri dev` to apply the updated text pool.
fn fallback_quote(length_choice: usize) -> String {
let topics = ["VIKINGS", "KNOWLEDGE", "FORTUNE", "STRATEGY", "FREEDOM"]; ## Android Packaging
let actions = [
"SAILED ACROSS THE UNKNOWN", Tauri v2 supports Android from the same project. Install Android Studio, the Android SDK, and the Rust Android targets, then run:
"DISCOVERED HIDDEN TRUTHS",
"FAVORED THE BRAVE MINDS", ```bash
"PROTECTED LIBERTY WITH SECRETS" cargo tauri android init
]; cargo tauri android dev
// ... cargo tauri android build
}
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()
}
``` ```
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) * [Rust](https://www.rust-lang.org/tools/install) (1.75 or newer)
* `cargo-tauri` CLI (Install via `cargo install tauri-cli --version "^2.0"`) * `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)* [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 ## 4. Hardware & Peripherals
* **Custom Printed Output (Thermal Printers)**: Direct USB/Bluetooth printer integration to print cryptographic escape room physical receipts/slips. * **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$). 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 ### 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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cipher Master</title> <title>Cipher Master</title>
<style> <style>
body { :root { --ink: #18222d; --muted: #6a7783; --panel: #fffdf8; --line: #ded7c9; --copper: #b75d3b; --teal: #176b6b; }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; * { box-sizing: border-box; }
background: #1e1e2e; 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); }
color: #cdd6f4; main { width: min(1050px, 100%); margin: auto; padding: clamp(1.25rem, 4vw, 4rem); }
padding: 2rem; header { display: flex; justify-content: space-between; gap: 1rem; align-items: end; margin-bottom: 2rem; }
margin: 0; .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; }
h1 { color: #89b4fa; margin-top: 0; } .mark { color: var(--teal); font-size: 2.5rem; }
.form-group { margin-bottom: 1.2rem; } .layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, .8fr); gap: 1.25rem; }
label { display: block; margin-bottom: 0.4rem; color: #a6adc8; } .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); }
select, button { .panel h2 { margin: 0 0 1.35rem; font-size: 1.35rem; }
width: 100%; .fields { display: grid; gap: 1rem; }
padding: 0.6rem; label { display: grid; gap: .42rem; color: var(--muted); font: 700 .75rem system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
border-radius: 6px; select, button { width: 100%; min-height: 48px; border: 1px solid var(--line); border-radius: 5px; padding: .75rem .85rem; font: 1rem system-ui, sans-serif; }
border: 1px solid #45475a; select { color: var(--ink); background: #fff; }
background: #313244; button { margin-top: 1.3rem; border: 0; color: #fff; background: var(--teal); font-weight: 700; cursor: pointer; transition: transform .2s, background .2s; }
color: #cdd6f4; button:hover { background: #0e5252; transform: translateY(-2px); }
font-size: 1rem; button:disabled { cursor: wait; opacity: .65; transform: none; }
} .side { display: grid; align-content: start; gap: 1rem; }
button { .status { color: var(--teal); font: 700 .78rem system-ui, sans-serif; text-transform: uppercase; letter-spacing: .08em; }
background: #89b4fa; #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; }
color: #11111b; .note { color: var(--muted); font: .92rem/1.5 system-ui, sans-serif; }
font-weight: bold; @media (max-width: 720px) { header { align-items: start; } .layout { grid-template-columns: 1fr; } .mark { font-size: 1.8rem; } }
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;
}
</style> </style>
</head> </head>
<body> <body>
<h1>ᛏ Cipher Master Engine</h1> <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>
<div class="form-group"> <section class="layout">
<label for="cipher">Cipher Type:</label> <div class="panel"><h2>Compose a challenge</h2><div class="fields">
<select id="cipher"> <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>
<option value="CustomFuthark">Elder Futhark Runes</option> <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>
<option value="Caesar">Caesar Shift</option> <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>
<option value="Atbash">Atbash Cipher</option> </div><button id="generate" onclick="generateCipher()">Generate challenge</button></div>
<option value="Substitution">Monoalphabetic Substitution</option> <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>
<option value="Vigenere">Vigenère Cipher</option> </section>
<option value="RailFence">Rail Fence (Zig-Zag)</option> </main>
<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>
<script> <script>
function getInvoke() { 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."); }
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) { 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; } }
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);
}
}
</script> </script>
</body> </body>
</html> </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::SliceRandom;
use rand::seq::IteratorRandom;
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -17,6 +18,7 @@ enum CipherType {
RailFence, RailFence,
Affine, Affine,
A1Z26, A1Z26,
FillInTheBlank,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
@@ -38,6 +40,11 @@ struct OllamaResponse {
response: String, 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")] #[tauri::command(rename_all = "snake_case")]
fn generate_cipher_challenge( fn generate_cipher_challenge(
cipher_type: String, cipher_type: String,
@@ -52,6 +59,7 @@ fn generate_cipher_challenge(
"Vigenere" => CipherType::Vigenere, "Vigenere" => CipherType::Vigenere,
"RailFence" => CipherType::RailFence, "RailFence" => CipherType::RailFence,
"Affine" => CipherType::Affine, "Affine" => CipherType::Affine,
"FillInTheBlank" => CipherType::FillInTheBlank,
_ => CipherType::A1Z26, _ => CipherType::A1Z26,
}; };
@@ -141,16 +149,21 @@ fn get_ai_quote(length_choice: usize) -> String {
} }
fn fallback_quote(length_choice: usize) -> String { fn fallback_quote(length_choice: usize) -> String {
let topics = ["VIKINGS", "KNOWLEDGE", "FORTUNE", "STRATEGY", "FREEDOM"]; let quotes = bundled_quotes();
let actions = ["SAILED ACROSS THE UNKNOWN", "DISCOVERED HIDDEN TRUTHS", "FAVORED THE BRAVE MINDS", "PROTECTED LIBERTY WITH SECRETS"]; let target_range = match length_choice {
1 => 5..=10,
2 => 11..=20,
_ => 21..=40,
};
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let base = format!("{} {}", topics.choose(&mut rng).unwrap(), actions.choose(&mut rng).unwrap()); quotes
if length_choice > 1 { .iter()
format!("{} AND UNLOCKED THE SECRETS OF THE ANCIENT RUNES", base) .filter(|quote| target_range.contains(&quote.split_whitespace().count()))
} else { .choose(&mut rng)
base .or_else(|| quotes.choose(&mut rng))
} .expect("quote fallback data must not be empty")
.to_uppercase()
} }
fn get_desktop_path() -> PathBuf { 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) { fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, String) {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let (ciphertext, key_info) = match cipher { let (ciphertext, key_info, custom_hint) = match cipher {
CipherType::CustomFuthark => { CipherType::CustomFuthark => {
let map = get_custom_futhark_map(); let map = get_custom_futhark_map();
let encrypted = text let encrypted = text
@@ -210,7 +223,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.chars() .chars()
.map(|c| *map.get(&c).unwrap_or(&c)) .map(|c| *map.get(&c).unwrap_or(&c))
.collect(); .collect();
(encrypted, "Elder Futhark Key Map".to_string()) (encrypted, "Elder Futhark Key Map".to_string(), None)
} }
CipherType::Caesar => { CipherType::Caesar => {
let shift = rng.gen_range(1..25) as u8; 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 } } else { c }
}) })
.collect(); .collect();
(encrypted, format!("Caesar Shift = {}", shift)) (encrypted, format!("Caesar Shift = {}", shift), None)
} }
CipherType::Atbash => { CipherType::Atbash => {
let encrypted = text let encrypted = text
@@ -235,7 +248,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c } } else { c }
}) })
.collect(); .collect();
(encrypted, "Atbash (Reversed Alphabet)".to_string()) (encrypted, "Atbash (Reversed Alphabet)".to_string(), None)
} }
CipherType::Substitution => { CipherType::Substitution => {
let mut alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); let mut alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
@@ -253,7 +266,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
}) })
.collect(); .collect();
let key_str = map.iter().map(|(k, v)| format!("{}->{}", k, v)).collect::<Vec<String>>().join(", "); 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 => { CipherType::Vigenere => {
let keys = vec!["ODIN", "THOR", "VALHALLA", "RUNE", "RUST", "CRYPT"]; 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 } } else { c }
}) })
.collect(); .collect();
(encrypted, format!("Vigenere Passphrase: {}", key)) (encrypted, format!("Vigenere Passphrase: {}", key), None)
} }
CipherType::RailFence => { CipherType::RailFence => {
let rails = rng.gen_range(2..=4); 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(); let encrypted = fence.into_iter().flatten().collect();
(encrypted, format!("Rail Fence Depth = {} Rails", rails)) (encrypted, format!("Rail Fence Depth = {} Rails", rails), None)
} }
CipherType::Affine => { CipherType::Affine => {
let a_options = vec![3, 5, 7, 9, 11, 15, 17, 19, 21, 23]; 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 } } else { c }
}) })
.collect(); .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 => { CipherType::A1Z26 => {
let encrypted = text let encrypted = text
@@ -322,14 +335,73 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.collect::<String>() .collect::<String>()
.trim_start_matches('-') .trim_start_matches('-')
.to_string(); .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) (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 { fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String {
let cleaned: Vec<char> = plaintext let cleaned: Vec<char> = plaintext
.chars() .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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()

View File

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

View File

@@ -19,6 +19,7 @@ enum CipherType {
RailFence, RailFence,
Affine, Affine,
A1Z26, A1Z26,
FillInTheBlank,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
@@ -54,6 +55,7 @@ fn generate_cipher_challenge(
"Vigenere" => CipherType::Vigenere, "Vigenere" => CipherType::Vigenere,
"RailFence" => CipherType::RailFence, "RailFence" => CipherType::RailFence,
"Affine" => CipherType::Affine, "Affine" => CipherType::Affine,
"FillInTheBlank" => CipherType::FillInTheBlank,
_ => CipherType::A1Z26, _ => 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) { fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, String) {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let (ciphertext, key_info) = match cipher { let (ciphertext, key_info, custom_hint) = match cipher {
CipherType::CustomFuthark => { CipherType::CustomFuthark => {
let map = get_custom_futhark_map(); let map = get_custom_futhark_map();
let encrypted = text let encrypted = text
@@ -212,7 +214,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.chars() .chars()
.map(|c| *map.get(&c).unwrap_or(&c)) .map(|c| *map.get(&c).unwrap_or(&c))
.collect(); .collect();
(encrypted, "Elder Futhark Key Map".to_string()) (encrypted, "Elder Futhark Key Map".to_string(), None)
} }
CipherType::Caesar => { CipherType::Caesar => {
let shift = rng.gen_range(1..25) as u8; 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 } } else { c }
}) })
.collect(); .collect();
(encrypted, format!("Caesar Shift = {}", shift)) (encrypted, format!("Caesar Shift = {}", shift), None)
} }
CipherType::Atbash => { CipherType::Atbash => {
let encrypted = text let encrypted = text
@@ -237,7 +239,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c } } else { c }
}) })
.collect(); .collect();
(encrypted, "Atbash (Reversed Alphabet)".to_string()) (encrypted, "Atbash (Reversed Alphabet)".to_string(), None)
} }
CipherType::Substitution => { CipherType::Substitution => {
let mut alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); let mut alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
@@ -255,7 +257,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
}) })
.collect(); .collect();
let key_str = map.iter().map(|(k, v)| format!("{}->{}", k, v)).collect::<Vec<String>>().join(", "); 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 => { CipherType::Vigenere => {
let keys = vec!["ODIN", "THOR", "VALHALLA", "RUNE", "RUST", "CRYPT"]; 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 } } else { c }
}) })
.collect(); .collect();
(encrypted, format!("Vigenere Passphrase: {}", key)) (encrypted, format!("Vigenere Passphrase: {}", key), None)
} }
CipherType::RailFence => { CipherType::RailFence => {
let rails = rng.gen_range(2..=4); 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(); let encrypted = fence.into_iter().flatten().collect();
(encrypted, format!("Rail Fence Depth = {} Rails", rails)) (encrypted, format!("Rail Fence Depth = {} Rails", rails), None)
} }
CipherType::Affine => { CipherType::Affine => {
let a_options = vec![3, 5, 7, 9, 11, 15, 17, 19, 21, 23]; 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 } } else { c }
}) })
.collect(); .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 => { CipherType::A1Z26 => {
let encrypted = text let encrypted = text
@@ -324,14 +326,73 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.collect::<String>() .collect::<String>()
.trim_start_matches('-') .trim_start_matches('-')
.to_string(); .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) (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 { fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String {
let cleaned: Vec<char> = plaintext let cleaned: Vec<char> = plaintext
.chars() .chars()