mirror of
https://github.com/ReVax-2007/cipher_master.git
synced 2026-09-17 21:32:27 +00:00
Compare commits
7 Commits
98917ccbf9
...
7f81eb6421
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f81eb6421 | ||
|
|
a2a8e0199a | ||
|
|
ca00312695 | ||
|
|
239dd0ad35 | ||
| 84fd2da49c | |||
|
|
7cddbcedf2 | ||
| 4e24e2d989 |
59
.github/workflows/release.yml
vendored
59
.github/workflows/release.yml
vendored
@@ -48,4 +48,61 @@ jobs:
|
|||||||
releaseName: 'Cipher Master ${{ 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
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
|
## 🌟 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
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$).
|
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
|
||||||
|
|
||||||
|
|||||||
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">
|
<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>
|
||||||
|
|||||||
12
src-tauri/data/quotes.json
Normal file
12
src-tauri/data/quotes.json
Normal 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."
|
||||||
|
]
|
||||||
@@ -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("e.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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
81
src/main.rs
81
src/main.rs
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user