mirror of
https://github.com/ReVax-2007/cipher_master.git
synced 2026-09-17 21:32:27 +00:00
Compare commits
11 Commits
4801b5348f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54ab679477 | ||
|
|
b114828dd3 | ||
|
|
7f81eb6421 | ||
|
|
a2a8e0199a | ||
|
|
ca00312695 | ||
|
|
239dd0ad35 | ||
| 84fd2da49c | |||
|
|
7cddbcedf2 | ||
| 4e24e2d989 | |||
|
|
98917ccbf9 | ||
| 5f91eb3542 |
66
.github/workflows/release.yml
vendored
66
.github/workflows/release.yml
vendored
@@ -39,14 +39,70 @@ jobs:
|
||||
patchelf
|
||||
|
||||
- name: Build Tauri App
|
||||
uses: tauri-apps/tauri-action@v1
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'Cipher Master v__VERSION__'
|
||||
projectPath: src-tauri
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Cipher Master ${{ github.ref_name }}'
|
||||
releaseBody: 'Download the compiled standalone application binaries below.'
|
||||
releaseDraft: true
|
||||
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
37
.gitignore
vendored
@@ -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
1638
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
93
README.md
93
README.md
@@ -13,7 +13,7 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
* **8 Cipher Engines**:
|
||||
* **9 Cipher Engines**:
|
||||
* **Elder Futhark Runes**: Custom handwritten runic character map.
|
||||
* **Caesar Shift**: Dynamic rotational shift ($1..25$).
|
||||
* **Atbash Cipher**: Reversed alphabet substitution.
|
||||
@@ -22,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
1
SoT.md
@@ -125,6 +125,7 @@ $$E(x) = (a \cdot x + b) \pmod{26}$$
|
||||
|
||||
|
||||
8. **A1Z26 Numeric**: Converts each letter to its 1-based numerical index separated by hyphens (e.g., $A=1, B=2, Z=26$).
|
||||
9. **Fill in the Blank**: Builds a randomized one-to-one mapping from every letter in $A..Z$ to a unique number in $01..26$. The challenge displays those numbers while preserving word boundaries, `cipher_hint.txt` reveals a small set of known letter mappings, and `cipher_solution.txt` stores the complete mapping.
|
||||
|
||||
### B. Difficulty Formatting Pipeline
|
||||
|
||||
|
||||
36
integrations/discord-bot/index.js
Normal file
36
integrations/discord-bot/index.js
Normal 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);
|
||||
7
integrations/discord-bot/package.json
Normal file
7
integrations/discord-bot/package.json
Normal 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" }
|
||||
}
|
||||
11
integrations/discord-bot/register-commands.js
Normal file
11
integrations/discord-bot/register-commands.js
Normal 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.");
|
||||
@@ -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>
|
||||
62
src-tauri/data/quotes.json
Normal file
62
src-tauri/data/quotes.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
"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.",
|
||||
"The important thing is not to stop questioning.",
|
||||
"Wonder is the beginning of wisdom.",
|
||||
"Everything is theoretically impossible, until it is done.",
|
||||
"If we knew what it was we were doing, it would not be called research.",
|
||||
"The unexamined life is not worth living.",
|
||||
"Somewhere, something incredible is waiting to be known.",
|
||||
"Curiosity is the wick in the candle of learning.",
|
||||
"Judge a man by his questions rather than by his answers.",
|
||||
"To raise new questions is to mark real advance.",
|
||||
"Invention is little more than a new combination of previous thoughts.",
|
||||
"It's not that I'm so smart, it's just that I stay with problems longer.",
|
||||
"Patience is bitter, but its fruit is sweet.",
|
||||
"By persistence, the snail reached the ark.",
|
||||
"Nature uses as little as possible of anything.",
|
||||
"In all things, the supreme excellence is simplicity.",
|
||||
"The secret of getting ahead is getting started.",
|
||||
"Fall seven times, stand up eight.",
|
||||
"A journey of a thousand miles begins with a single step.",
|
||||
"What we know is a drop, what we don't know is an ocean.",
|
||||
"Little by little, a little becomes a lot.",
|
||||
"It is the mark of an educated mind to entertain a thought without accepting it.",
|
||||
"The truth is rarely pure and never simple.",
|
||||
"There are no whole truths; all truths are half-truths.",
|
||||
"Not everything that can be counted counts.",
|
||||
"To see what is in front of one's nose needs a constant struggle.",
|
||||
"Perspective is worth eighty points of intelligence.",
|
||||
"What you seek is seeking you.",
|
||||
"The eye sees only what the mind is prepared to comprehend.",
|
||||
"All our knowledge begins with the senses.",
|
||||
"It is impossible for a man to learn what he thinks he already knows.",
|
||||
"Knowledge speaks, but wisdom listens.",
|
||||
"To know what you know and what you do not know is true knowledge.",
|
||||
"An investment in knowledge pays the best interest.",
|
||||
"Thinking is the hardest work there is.",
|
||||
"Action is the foundational key to all success.",
|
||||
"In theory, there is no difference between theory and practice.",
|
||||
"Tell me and I forget, teach me and I may remember.",
|
||||
"The art of being wise is knowing what to overlook.",
|
||||
"Change your thoughts and you change your world.",
|
||||
"Doubt is not a pleasant condition, but certainty is absurd.",
|
||||
"The most beautiful experience we can have is the mysterious.",
|
||||
"When you have eliminated the impossible, whatever remains must be the truth.",
|
||||
"There is no heavier burden than a great potential.",
|
||||
"A puzzle is only hard until you see the missing piece.",
|
||||
"To ask the right question is already half the solution.",
|
||||
"The universe is full of magical things waiting for our wits to grow.",
|
||||
"Wonder is the feeling of a philosopher.",
|
||||
"Errors are not in the art, but in the artificer.",
|
||||
"A paradox is a signpost on the road to truth.",
|
||||
"Significant problems cannot be solved with the same level of thinking that created them.",
|
||||
]
|
||||
@@ -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("e.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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
81
src/main.rs
81
src/main.rs
@@ -19,6 +19,7 @@ enum CipherType {
|
||||
RailFence,
|
||||
Affine,
|
||||
A1Z26,
|
||||
FillInTheBlank,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
@@ -54,6 +55,7 @@ fn generate_cipher_challenge(
|
||||
"Vigenere" => CipherType::Vigenere,
|
||||
"RailFence" => CipherType::RailFence,
|
||||
"Affine" => CipherType::Affine,
|
||||
"FillInTheBlank" => CipherType::FillInTheBlank,
|
||||
_ => CipherType::A1Z26,
|
||||
};
|
||||
|
||||
@@ -204,7 +206,7 @@ fn get_custom_futhark_map() -> HashMap<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()
|
||||
|
||||
Reference in New Issue
Block a user