10 Commits

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

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

## Changes

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

## Discord Setup

The Discord integration includes:

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

Supported commands:

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

## Release

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

## Validation

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

## Android Note

The documentation includes the Tauri Android initialization and build commands. The current release workflow still produces desktop artifacts only. Android project initialization and Android CI packaging should be added separately before publishing an Android binary.
2026-09-16 17:16:40 -04:00
84fd2da49c feat: add integrations and mobile-ready setup
Add bundled JSON quote fallback, responsive UI, Discord slash commands, Android setup documentation, and complete integration installation instructions. Set the Tauri application version to 1.1.0 for the release.
2026-09-16 17:03:27 -04:00
Xavier Voet
7cddbcedf2 Merge pull request #5 from ReVax-2007/feature/fill-in-the-blank
feat: add fill-in-the-blank cipher mode
2026-09-16 14:01:41 -04:00
4e24e2d989 feat: add fill-in-the-blank cipher mode 2026-09-15 17:46:59 -04:00
Xavier Voet
98917ccbf9 Merge pull request #4 from ReVax-2007/Fix--release-Build
fixed Release.
2026-09-14 11:38:34 -04:00
5f91eb3542 fixed Release. 2026-09-14 11:38:08 -04:00
14 changed files with 2174 additions and 166 deletions

View File

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

@@ -1 +1,36 @@
/target
# Rust and Tauri build output
/target/
/src-tauri/target/
# Operating-system metadata
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100/
.Trashes/
.fseventsd
.VolumeIcon.icns
Desktop.ini
Thumbs.db
ehthumbs.db
$RECYCLE.BIN/
# Editor and tool state
.idea/
*.swp
*.swo
*.swn
# Logs, temporary files, and local environment settings
*.log
*.tmp
*.temp
.env
.env.*
!.env.example
# Files generated by the application when run from the repository
cipher_challenge.txt
cipher_hint.txt
cipher_solution.txt

1638
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
## 🌟 Key Features
* **8 Cipher Engines**:
* **9 Cipher Engines**:
* **Elder Futhark Runes**: Custom handwritten runic character map.
* **Caesar Shift**: Dynamic rotational shift ($1..25$).
* **Atbash Cipher**: Reversed alphabet substitution.
@@ -22,7 +22,8 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
* **Rail Fence (Zig-Zag)**: Multi-rail transposition matrix.
* **Affine Cipher**: Mathematical linear function cipher ($E(x) = (ax + b) \pmod{26}$).
* **A1Z26 Numeric**: Positional index substitution.
* **Local AI Integration & Fallback Support**: Connects to a local [Ollama](https://ollama.com/) instance (`llama3.2:1b`) to generate dynamic quotes. If Ollama is not installed or running, the engine automatically uses internal text prompts.
* **Fill in the Blank**: Assigns every letter a unique number from `01` to `26`, reveals a few mappings as clues, and preserves repeated-letter patterns.
* **Local AI Integration & Fallback Support**: Connects to a local [Ollama](https://ollama.com/) instance (`llama3.2:1b`) to generate dynamic quotes. If Ollama is not installed or running, the engine selects a quote from `src-tauri/data/quotes.json`.
* **Difficulty Scaling**:
* **Easy**: Preserves original word boundaries, spaces, and punctuation. Generates detailed letter frequency and vowel count hints.
* **Medium**: Converts text to uppercase and strips all punctuation.
@@ -31,6 +32,10 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
* `cipher_challenge.txt`: Formatted ciphertext ready to solve.
* `cipher_hint.txt`: Cryptanalytic structural metrics and riddle clues.
* `cipher_solution.txt`: Full original plaintext and decryption parameters/keys.
### Fill in the Blank Mode
This mode creates a numeric substitution puzzle. Each letter is assigned a unique number from `01` to `26`; the same letter always produces the same number, while different letters never share a number. Word boundaries remain visible in the challenge, and `cipher_hint.txt` reveals a small number of starter mappings based on the selected difficulty. The complete alphabet mapping is available only in `cipher_solution.txt`.
* **Cross-Platform Support**: Native desktop GUI built for macOS and Windows.
---
@@ -39,28 +44,73 @@ An AI-powered desktop application built with **Rust** and **Tauri v2** that gene
If you do not want to run Ollama locally or prefer using your own custom paragraphs, quotes, or text passages:
1. Open `src-tauri/src/lib.rs` in your text editor.
2. Locate the `fallback_quote` function:
```rust
fn fallback_quote(length_choice: usize) -> String {
let topics = ["VIKINGS", "KNOWLEDGE", "FORTUNE", "STRATEGY", "FREEDOM"];
let actions = [
"SAILED ACROSS THE UNKNOWN",
"DISCOVERED HIDDEN TRUTHS",
"FAVORED THE BRAVE MINDS",
"PROTECTED LIBERTY WITH SECRETS"
];
// ...
}
3. To hardcode a custom text passage or quote directly, modify the function to return your desired text string:
```rust
fn fallback_quote(_length_choice: usize) -> String {
"YOUR CUSTOM PARAGRAPH OR PHRASE GOES HERE".to_string()
}
1. Edit `src-tauri/data/quotes.json` and add plain JSON strings to the array.
2. Keep the file as valid JSON. Quotes are selected by word count when possible, then a random entry is used as a final fallback.
3. Rebuild or run `cargo tauri dev` to apply the updated text pool.
## Android Packaging
Tauri v2 supports Android from the same project. Install Android Studio, the Android SDK, and the Rust Android targets, then run:
```bash
cargo tauri android init
cargo tauri android dev
cargo tauri android build
```
The generated Android project lives under `src-tauri/gen/android` and should be reviewed before committing. The responsive frontend and bundled quote library work without a desktop-only dependency; Ollama remains optional and is normally unavailable on a phone.
4. Rebuild or run `cargo tauri dev` to apply your custom text pool.
## Discord Slash Commands Setup
The opt-in starter bot lives in `integrations/discord-bot`. It supports `/cipher`, `/hint`, and `/solution`. Challenges are stored in memory per guild and user; hints and solutions are sent as ephemeral replies.
### 1. Create the Discord application
1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and select **New Application**.
2. Open **General Information** and copy the **Application ID**. This is `DISCORD_CLIENT_ID`.
3. Open **Bot**, select **Reset Token**, and copy the token once. This is `DISCORD_TOKEN`. Treat it like a password and never commit it.
4. Under **OAuth2 > URL Generator**, select the `bot` and `applications.commands` scopes.
5. Select the `Send Messages` bot permission, open the generated URL, and invite the bot to a test server.
### 2. Install and configure the bot
From the repository root:
```bash
cd integrations/discord-bot
npm install
```
Set the credentials in the same terminal session. macOS/Linux:
```bash
export DISCORD_TOKEN="paste-your-bot-token-here"
export DISCORD_CLIENT_ID="paste-your-application-id-here"
```
PowerShell:
```powershell
$env:DISCORD_TOKEN = "paste-your-bot-token-here"
$env:DISCORD_CLIENT_ID = "paste-your-application-id-here"
```
### 3. Register and run the commands
```bash
npm run register
npm start
```
The registration command installs the commands globally for the application. Discord can take up to an hour to propagate global commands. Keep `npm start` running while testing, or run it with a process manager for a hosted deployment.
Available commands:
* `/cipher` creates a Caesar or Atbash challenge. Use the optional `type` argument to choose the cipher.
* `/hint` returns a private hint for your current challenge.
* `/solution` returns the plaintext and key privately.
The bot uses the bundled `src-tauri/data/quotes.json` library. It does not call Ollama or persist challenges after restart. Add a database before running multiple bot instances or requiring durable challenge history.
---
@@ -105,6 +155,7 @@ cipher_game/
* [Rust](https://www.rust-lang.org/tools/install) (1.75 or newer)
* `cargo-tauri` CLI (Install via `cargo install tauri-cli --version "^2.0"`)
* *(Optional)* [Ollama](https://ollama.com/) running locally on port `11434` with `llama3.2:1b` installed (`ollama pull llama3.2:1b`).
* *(Optional, Discord bot only)* [Node.js](https://nodejs.org/) 20 or newer and npm.
---

View File

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

1
SoT.md
View File

@@ -125,6 +125,7 @@ $$E(x) = (a \cdot x + b) \pmod{26}$$
8. **A1Z26 Numeric**: Converts each letter to its 1-based numerical index separated by hyphens (e.g., $A=1, B=2, Z=26$).
9. **Fill in the Blank**: Builds a randomized one-to-one mapping from every letter in $A..Z$ to a unique number in $01..26$. The challenge displays those numbers while preserving word boundaries, `cipher_hint.txt` reveals a small set of known letter mappings, and `cipher_solution.txt` stores the complete mapping.
### B. Difficulty Formatting Pipeline

View File

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

View File

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

View File

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

View File

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

View 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.",
]

View File

@@ -1,4 +1,5 @@
use rand::seq::SliceRandom;
use rand::seq::IteratorRandom;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@@ -17,6 +18,7 @@ enum CipherType {
RailFence,
Affine,
A1Z26,
FillInTheBlank,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
@@ -38,6 +40,11 @@ struct OllamaResponse {
response: String,
}
fn bundled_quotes() -> Vec<String> {
serde_json::from_str(include_str!("../data/quotes.json"))
.expect("bundled quote data must be valid JSON")
}
#[tauri::command(rename_all = "snake_case")]
fn generate_cipher_challenge(
cipher_type: String,
@@ -52,6 +59,7 @@ fn generate_cipher_challenge(
"Vigenere" => CipherType::Vigenere,
"RailFence" => CipherType::RailFence,
"Affine" => CipherType::Affine,
"FillInTheBlank" => CipherType::FillInTheBlank,
_ => CipherType::A1Z26,
};
@@ -141,16 +149,21 @@ fn get_ai_quote(length_choice: usize) -> String {
}
fn fallback_quote(length_choice: usize) -> String {
let topics = ["VIKINGS", "KNOWLEDGE", "FORTUNE", "STRATEGY", "FREEDOM"];
let actions = ["SAILED ACROSS THE UNKNOWN", "DISCOVERED HIDDEN TRUTHS", "FAVORED THE BRAVE MINDS", "PROTECTED LIBERTY WITH SECRETS"];
let quotes = bundled_quotes();
let target_range = match length_choice {
1 => 5..=10,
2 => 11..=20,
_ => 21..=40,
};
let mut rng = rand::thread_rng();
let base = format!("{} {}", topics.choose(&mut rng).unwrap(), actions.choose(&mut rng).unwrap());
if length_choice > 1 {
format!("{} AND UNLOCKED THE SECRETS OF THE ANCIENT RUNES", base)
} else {
base
}
quotes
.iter()
.filter(|quote| target_range.contains(&quote.split_whitespace().count()))
.choose(&mut rng)
.or_else(|| quotes.choose(&mut rng))
.expect("quote fallback data must not be empty")
.to_uppercase()
}
fn get_desktop_path() -> PathBuf {
@@ -202,7 +215,7 @@ fn get_custom_futhark_map() -> HashMap<char, char> {
fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, String) {
let mut rng = rand::thread_rng();
let (ciphertext, key_info) = match cipher {
let (ciphertext, key_info, custom_hint) = match cipher {
CipherType::CustomFuthark => {
let map = get_custom_futhark_map();
let encrypted = text
@@ -210,7 +223,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.chars()
.map(|c| *map.get(&c).unwrap_or(&c))
.collect();
(encrypted, "Elder Futhark Key Map".to_string())
(encrypted, "Elder Futhark Key Map".to_string(), None)
}
CipherType::Caesar => {
let shift = rng.gen_range(1..25) as u8;
@@ -223,7 +236,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, format!("Caesar Shift = {}", shift))
(encrypted, format!("Caesar Shift = {}", shift), None)
}
CipherType::Atbash => {
let encrypted = text
@@ -235,7 +248,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, "Atbash (Reversed Alphabet)".to_string())
(encrypted, "Atbash (Reversed Alphabet)".to_string(), None)
}
CipherType::Substitution => {
let mut alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
@@ -253,7 +266,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
})
.collect();
let key_str = map.iter().map(|(k, v)| format!("{}->{}", k, v)).collect::<Vec<String>>().join(", ");
(encrypted, format!("Substitution Table: [{}]", key_str))
(encrypted, format!("Substitution Table: [{}]", key_str), None)
}
CipherType::Vigenere => {
let keys = vec!["ODIN", "THOR", "VALHALLA", "RUNE", "RUST", "CRYPT"];
@@ -272,7 +285,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, format!("Vigenere Passphrase: {}", key))
(encrypted, format!("Vigenere Passphrase: {}", key), None)
}
CipherType::RailFence => {
let rails = rng.gen_range(2..=4);
@@ -288,7 +301,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
}
let encrypted = fence.into_iter().flatten().collect();
(encrypted, format!("Rail Fence Depth = {} Rails", rails))
(encrypted, format!("Rail Fence Depth = {} Rails", rails), None)
}
CipherType::Affine => {
let a_options = vec![3, 5, 7, 9, 11, 15, 17, 19, 21, 23];
@@ -306,7 +319,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b))
(encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b), None)
}
CipherType::A1Z26 => {
let encrypted = text
@@ -322,14 +335,73 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.collect::<String>()
.trim_start_matches('-')
.to_string();
(encrypted, "A1Z26 Position Mapping".to_string())
(encrypted, "A1Z26 Position Mapping".to_string(), None)
}
CipherType::FillInTheBlank => {
let (challenge, key, hint) = generate_fill_in_the_blank(text, diff, &mut rng);
(challenge, key, Some(hint))
}
};
let content_hint = generate_content_hints(text, diff);
let content_hint = custom_hint.unwrap_or_else(|| generate_content_hints(text, diff));
(ciphertext, key_info, content_hint)
}
fn generate_fill_in_the_blank(
text: &str,
diff: Difficulty,
rng: &mut impl Rng,
) -> (String, String, String) {
let alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let mut numbers: Vec<u8> = (1..=26).collect();
numbers.shuffle(rng);
let mapping: HashMap<char, u8> = alphabet.iter().copied().zip(numbers).collect();
let challenge = text
.chars()
.map(|character| {
if character.is_ascii_alphabetic() {
format!("{:02} ", mapping[&character.to_ascii_uppercase()])
} else if character.is_whitespace() {
" ".to_string()
} else {
character.to_string()
}
})
.collect::<String>()
.trim_end()
.to_string();
let used_letters: Vec<char> = alphabet
.iter()
.copied()
.filter(|letter| text.to_ascii_uppercase().contains(*letter))
.collect();
let clue_count = match diff {
Difficulty::Easy => 4,
Difficulty::Medium => 3,
Difficulty::Hard => 2,
}
.min(used_letters.len());
let clues = used_letters
.iter()
.take(clue_count)
.map(|letter| format!("{} = {:02}", letter, mapping[letter]))
.collect::<Vec<String>>();
let key = alphabet
.iter()
.map(|letter| format!("{}={:02}", letter, mapping[letter]))
.collect::<Vec<String>>()
.join(", ");
let hint = format!(
"FILL-IN-THE-BLANK RULES:\n- Each number represents one letter.\n- Every letter uses a unique number from 01 to 26.\n- Repeated numbers represent repeated letters.\n\nKNOWN LETTER MAPPINGS:\n{}",
clues.join(", ")
);
(challenge, format!("Fill-in-the-Blank Number Key: {}", key), hint)
}
fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String {
let cleaned: Vec<char> = plaintext
.chars()
@@ -404,6 +476,31 @@ fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fill_in_the_blank_uses_unique_stable_numbers_and_clues() {
let (challenge, key, hint) = generate_fill_in_the_blank(
"ABBA CAB",
Difficulty::Easy,
&mut rand::thread_rng(),
);
let numbers: Vec<&str> = challenge
.split_whitespace()
.filter(|token| token.chars().all(|character| character.is_ascii_digit()))
.collect();
assert_eq!(numbers.len(), 7);
assert_eq!(numbers[1], numbers[2]);
assert_eq!(numbers[0], numbers[3]);
assert!(key.contains("A="));
assert!(key.contains("Z="));
assert!(hint.contains("KNOWN LETTER MAPPINGS:"));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()

View File

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

View File

@@ -19,6 +19,7 @@ enum CipherType {
RailFence,
Affine,
A1Z26,
FillInTheBlank,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
@@ -54,6 +55,7 @@ fn generate_cipher_challenge(
"Vigenere" => CipherType::Vigenere,
"RailFence" => CipherType::RailFence,
"Affine" => CipherType::Affine,
"FillInTheBlank" => CipherType::FillInTheBlank,
_ => CipherType::A1Z26,
};
@@ -204,7 +206,7 @@ fn get_custom_futhark_map() -> HashMap<char, char> {
fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String, String) {
let mut rng = rand::thread_rng();
let (ciphertext, key_info) = match cipher {
let (ciphertext, key_info, custom_hint) = match cipher {
CipherType::CustomFuthark => {
let map = get_custom_futhark_map();
let encrypted = text
@@ -212,7 +214,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.chars()
.map(|c| *map.get(&c).unwrap_or(&c))
.collect();
(encrypted, "Elder Futhark Key Map".to_string())
(encrypted, "Elder Futhark Key Map".to_string(), None)
}
CipherType::Caesar => {
let shift = rng.gen_range(1..25) as u8;
@@ -225,7 +227,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, format!("Caesar Shift = {}", shift))
(encrypted, format!("Caesar Shift = {}", shift), None)
}
CipherType::Atbash => {
let encrypted = text
@@ -237,7 +239,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, "Atbash (Reversed Alphabet)".to_string())
(encrypted, "Atbash (Reversed Alphabet)".to_string(), None)
}
CipherType::Substitution => {
let mut alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
@@ -255,7 +257,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
})
.collect();
let key_str = map.iter().map(|(k, v)| format!("{}->{}", k, v)).collect::<Vec<String>>().join(", ");
(encrypted, format!("Substitution Table: [{}]", key_str))
(encrypted, format!("Substitution Table: [{}]", key_str), None)
}
CipherType::Vigenere => {
let keys = vec!["ODIN", "THOR", "VALHALLA", "RUNE", "RUST", "CRYPT"];
@@ -274,7 +276,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, format!("Vigenere Passphrase: {}", key))
(encrypted, format!("Vigenere Passphrase: {}", key), None)
}
CipherType::RailFence => {
let rails = rng.gen_range(2..=4);
@@ -290,7 +292,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
}
let encrypted = fence.into_iter().flatten().collect();
(encrypted, format!("Rail Fence Depth = {} Rails", rails))
(encrypted, format!("Rail Fence Depth = {} Rails", rails), None)
}
CipherType::Affine => {
let a_options = vec![3, 5, 7, 9, 11, 15, 17, 19, 21, 23];
@@ -308,7 +310,7 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
} else { c }
})
.collect();
(encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b))
(encrypted, format!("Affine Cipher: E(x) = ({}x + {}) mod 26", a, b), None)
}
CipherType::A1Z26 => {
let encrypted = text
@@ -324,14 +326,73 @@ fn encrypt(text: &str, cipher: CipherType, diff: Difficulty) -> (String, String,
.collect::<String>()
.trim_start_matches('-')
.to_string();
(encrypted, "A1Z26 Position Mapping".to_string())
(encrypted, "A1Z26 Position Mapping".to_string(), None)
}
CipherType::FillInTheBlank => {
let (challenge, key, hint) = generate_fill_in_the_blank(text, diff, &mut rng);
(challenge, key, Some(hint))
}
};
let content_hint = generate_content_hints(text, diff);
let content_hint = custom_hint.unwrap_or_else(|| generate_content_hints(text, diff));
(ciphertext, key_info, content_hint)
}
fn generate_fill_in_the_blank(
text: &str,
diff: Difficulty,
rng: &mut impl Rng,
) -> (String, String, String) {
let alphabet: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let mut numbers: Vec<u8> = (1..=26).collect();
numbers.shuffle(rng);
let mapping: HashMap<char, u8> = alphabet.iter().copied().zip(numbers).collect();
let challenge = text
.chars()
.map(|character| {
if character.is_ascii_alphabetic() {
format!("{:02} ", mapping[&character.to_ascii_uppercase()])
} else if character.is_whitespace() {
" ".to_string()
} else {
character.to_string()
}
})
.collect::<String>()
.trim_end()
.to_string();
let used_letters: Vec<char> = alphabet
.iter()
.copied()
.filter(|letter| text.to_ascii_uppercase().contains(*letter))
.collect();
let clue_count = match diff {
Difficulty::Easy => 4,
Difficulty::Medium => 3,
Difficulty::Hard => 2,
}
.min(used_letters.len());
let clues = used_letters
.iter()
.take(clue_count)
.map(|letter| format!("{} = {:02}", letter, mapping[letter]))
.collect::<Vec<String>>();
let key = alphabet
.iter()
.map(|letter| format!("{}={:02}", letter, mapping[letter]))
.collect::<Vec<String>>()
.join(", ");
let hint = format!(
"FILL-IN-THE-BLANK RULES:\n- Each number represents one letter.\n- Every letter uses a unique number from 01 to 26.\n- Repeated numbers represent repeated letters.\n\nKNOWN LETTER MAPPINGS:\n{}",
clues.join(", ")
);
(challenge, format!("Fill-in-the-Blank Number Key: {}", key), hint)
}
fn generate_content_hints(plaintext: &str, diff: Difficulty) -> String {
let cleaned: Vec<char> = plaintext
.chars()