From 7bf292d64eae5aaeaef2758cf948fcbd13aa4384 Mon Sep 17 00:00:00 2001 From: Leopere Date: Sun, 8 Feb 2026 16:42:52 -0500 Subject: [PATCH] Add Quantum 8 Ball tool and click-to-copy for all outputs New QTRNG tool: Quantum 8 Ball with all 20 classic Magic 8 Ball responses, selected via rejection sampling on quantum camera noise. Includes /8ball API endpoint, sentiment classification (positive/ neutral/negative), and a fun dark-orb UI with shake animation and color-coded answers. Both output boxes (random bytes + tools) now support click-to-copy with a toast notification. Co-authored-by: Cursor --- src/index.html | 451 ++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 3 +- src/main.rs | 3 +- src/qrng_handlers.rs | 86 +++++++- src/tools/eightball.rs | 103 ++++++++++ src/tools/mod.rs | 6 +- src/tools/password.rs | 12 ++ 7 files changed, 649 insertions(+), 15 deletions(-) create mode 100644 src/tools/eightball.rs diff --git a/src/index.html b/src/index.html index 541b122..4dd18a9 100644 --- a/src/index.html +++ b/src/index.html @@ -5,6 +5,9 @@ Camera TRNG - True Random Number Generator + + + - + + + diff --git a/src/lib.rs b/src/lib.rs index 578c834..953d75c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,8 @@ pub use entropy::{ }; // Re-export the OpenSSL provider init for cdylib -pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH}; +pub use tools::{roll_dice, generate_password, dice_bytes_needed, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT as DICE_MAX_COUNT, MAX_SIDES, MIN_SIDES, DEFAULT_LENGTH as PASSWORD_DEFAULT_LENGTH, MAX_LENGTH as PASSWORD_MAX_LENGTH, filter_ambiguous}; +pub use tools::{eightball_shake, eightball_bytes_needed, eightball_sentiment, EIGHTBALL_RESPONSES, EIGHTBALL_NUM_RESPONSES}; pub use provider::OSSL_provider_init; diff --git a/src/main.rs b/src/main.rs index 5faefd1..8a3c906 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use axum::{ }; use camera_trng::{extract_entropy, list_cameras, subscribe_entropy, unsubscribe_entropy, ensure_producer_running, test_camera, CameraConfig, CHUNK_SIZE}; mod qrng_handlers; -use qrng_handlers::{get_dice, get_password, get_coin}; +use qrng_handlers::{get_dice, get_password, get_coin, get_eightball}; use bytes::Bytes; use std::sync::{Arc, Mutex}; use serde_json::json; @@ -74,6 +74,7 @@ async fn main() -> Result<(), Box> { .route("/dice", get(get_dice)) .route("/password", get(get_password)) .route("/coin", get(get_coin)) + .route("/8ball", get(get_eightball)) .route("/health", get(health)) .route("/.well-known/mcp.json", get(mcp_wellknown)) .route("/.well-known/skill.md", get(get_skill_md)) diff --git a/src/qrng_handlers.rs b/src/qrng_handlers.rs index 0335d77..240cf65 100644 --- a/src/qrng_handlers.rs +++ b/src/qrng_handlers.rs @@ -1,4 +1,4 @@ -//! HTTP handlers for QTRNG-backed tools: dice, passwords. +//! HTTP handlers for QTRNG-backed tools: dice, passwords, coin, 8 ball. use axum::{ extract::Query, @@ -14,6 +14,9 @@ use camera_trng::{ charset_full, charset_hex, charset_from_flags, + filter_ambiguous, + eightball_shake, + eightball_bytes_needed, CameraConfig, DEFAULT_SIDES, DEFAULT_COUNT, @@ -45,6 +48,14 @@ pub struct PasswordQuery { pub length: Option, #[serde(default)] pub charset: Option, + #[serde(default)] + pub exclude_ambiguous: bool, +} + +#[derive(Deserialize)] +pub struct EightBallQuery { + #[serde(default)] + pub question: Option, } pub async fn get_dice(Query(params): Query) -> Response { @@ -52,15 +63,21 @@ pub async fn get_dice(Query(params): Query) -> Response { let count = params.count; if sides < MIN_SIDES || sides > MAX_SIDES { - return (StatusCode::BAD_REQUEST, format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES)).into_response(); + return (StatusCode::BAD_REQUEST, + format!("d must be between {} and {}", MIN_SIDES, MAX_SIDES)) + .into_response(); } if count == 0 || count > camera_trng::DICE_MAX_COUNT { - return (StatusCode::BAD_REQUEST, format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT)).into_response(); + return (StatusCode::BAD_REQUEST, + format!("count must be 1..{}", camera_trng::DICE_MAX_COUNT)) + .into_response(); } let need = dice_bytes_needed(sides, count); let config = CameraConfig::from_env(); - let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await; + let result = tokio::task::spawn_blocking(move || { + extract_entropy(need, &config) + }).await; let data = match result { Ok(Ok(d)) => d, @@ -87,12 +104,14 @@ pub async fn get_dice(Query(params): Query) -> Response { } pub async fn get_password(Query(params): Query) -> Response { - let length = params.length.unwrap_or(PASSWORD_DEFAULT_LENGTH).min(PASSWORD_MAX_LENGTH); + let length = params.length + .unwrap_or(PASSWORD_DEFAULT_LENGTH) + .min(PASSWORD_MAX_LENGTH); if length == 0 { return (StatusCode::BAD_REQUEST, "length must be >= 1").into_response(); } - let charset: Vec = match params.charset.as_deref() { + let mut charset: Vec = match params.charset.as_deref() { Some("alphanumeric") | None => charset_alphanumeric(), Some("full") => charset_full(), Some("hex") => charset_hex(), @@ -107,9 +126,20 @@ pub async fn get_password(Query(params): Query) -> Response { } }; + if params.exclude_ambiguous { + charset = filter_ambiguous(&charset); + if charset.is_empty() { + return (StatusCode::BAD_REQUEST, + "charset empty after removing ambiguous chars") + .into_response(); + } + } + let need = length * 4 + 64; let config = CameraConfig::from_env(); - let result = tokio::task::spawn_blocking(move || extract_entropy(need, &config)).await; + let result = tokio::task::spawn_blocking(move || { + extract_entropy(need, &config) + }).await; let data = match result { Ok(Ok(d)) => d, @@ -119,12 +149,13 @@ pub async fn get_password(Query(params): Query) -> Response { let password = match generate_password(length, &charset, &data) { Some(p) => p, - None => return (StatusCode::INTERNAL_SERVER_ERROR, "password generation failed").into_response(), + None => return (StatusCode::INTERNAL_SERVER_ERROR, "generation failed").into_response(), }; let body = serde_json::json!({ "password": password, "length": length, + "exclude_ambiguous": params.exclude_ambiguous, }); Response::builder() .header(header::CONTENT_TYPE, "application/json") @@ -136,7 +167,9 @@ pub async fn get_password(Query(params): Query) -> Response { /// Quantum coin flip: one random bit from QTRNG. pub async fn get_coin() -> Response { let config = CameraConfig::from_env(); - let result = tokio::task::spawn_blocking(move || extract_entropy(1, &config)).await; + let result = tokio::task::spawn_blocking(move || { + extract_entropy(1, &config) + }).await; let data = match result { Ok(Ok(d)) if !d.is_empty() => d[0], _ => return (StatusCode::INTERNAL_SERVER_ERROR, "entropy failed").into_response(), @@ -149,3 +182,38 @@ pub async fn get_coin() -> Response { .body(axum::body::Body::from(body.to_string())) .unwrap() } + +/// Quantum 8 Ball: ask a question, get a quantumly-random Magic 8 Ball answer. +pub async fn get_eightball(Query(params): Query) -> Response { + let need = eightball_bytes_needed(); + let config = CameraConfig::from_env(); + let result = tokio::task::spawn_blocking(move || { + extract_entropy(need, &config) + }).await; + + let data = match result { + Ok(Ok(d)) => d, + Ok(Err(e)) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let (index, answer, sentiment) = match eightball_shake(&data) { + Some(r) => r, + None => return (StatusCode::INTERNAL_SERVER_ERROR, "8 ball shaking failed").into_response(), + }; + + let mut body = serde_json::json!({ + "answer": answer, + "sentiment": sentiment, + "index": index, + }); + if let Some(q) = params.question { + body["question"] = serde_json::Value::String(q); + } + + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-store") + .body(axum::body::Body::from(body.to_string())) + .unwrap() +} diff --git a/src/tools/eightball.rs b/src/tools/eightball.rs new file mode 100644 index 0000000..a979755 --- /dev/null +++ b/src/tools/eightball.rs @@ -0,0 +1,103 @@ +//! Quantum 8 Ball - a silly Magic 8 Ball powered by QTRNG. +//! +//! Uses quantum random bytes from camera noise to pick a response, +//! because the universe should decide your fate, not a PRNG. + +/// The classic 20 Magic 8 Ball responses, split by sentiment. +pub const RESPONSES: &[&str] = &[ + // Positive (10) + "It is certain.", + "It is decidedly so.", + "Without a doubt.", + "Yes, definitely.", + "You may rely on it.", + "As I see it, yes.", + "Most likely.", + "Outlook good.", + "Yes.", + "Signs point to yes.", + // Neutral (5) + "Reply hazy, try again.", + "Ask again later.", + "Better not tell you now.", + "Cannot predict now.", + "Concentrate and ask again.", + // Negative (5) + "Don't count on it.", + "My reply is no.", + "My sources say no.", + "Outlook not so good.", + "Very doubtful.", +]; + +/// Number of responses. +pub const NUM_RESPONSES: usize = 20; + +/// Sentiment category for a response index. +pub fn sentiment(index: usize) -> &'static str { + match index { + 0..=9 => "positive", + 10..=14 => "neutral", + _ => "negative", + } +} + +/// Pick a response using quantum random bytes. Uses rejection sampling +/// for uniform distribution across 20 responses. +/// Returns `(index, response, sentiment)` or `None` if not enough bytes. +pub fn shake(bytes: &[u8]) -> Option<(usize, &'static str, &'static str)> { + let n = NUM_RESPONSES as u32; + let threshold = (u32::MAX / n) * n; + let mut idx = 0; + loop { + if idx + 4 > bytes.len() { + return None; + } + let word = u32::from_be_bytes([bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3]]); + idx += 4; + if word < threshold { + let i = (word % n) as usize; + return Some((i, RESPONSES[i], sentiment(i))); + } + } +} + +/// Estimate bytes needed (very generous for rejection sampling with n=20). +#[allow(dead_code)] +pub fn bytes_needed() -> usize { + // n=20: threshold acceptance is ~99.99%, so 8 bytes is more than enough. + // We request 16 to be safe. + 16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shake_returns_valid_response() { + let bytes = [0u8; 32]; + let result = shake(&bytes); + assert!(result.is_some()); + let (i, resp, sent) = result.unwrap(); + assert!(i < NUM_RESPONSES); + assert_eq!(resp, RESPONSES[i]); + assert!(["positive", "neutral", "negative"].contains(&sent)); + } + + #[test] + fn shake_needs_at_least_4_bytes() { + let bytes = [0u8; 3]; + assert!(shake(&bytes).is_none()); + } + + #[test] + fn sentiment_categories() { + assert_eq!(sentiment(0), "positive"); + assert_eq!(sentiment(9), "positive"); + assert_eq!(sentiment(10), "neutral"); + assert_eq!(sentiment(14), "neutral"); + assert_eq!(sentiment(15), "negative"); + assert_eq!(sentiment(19), "negative"); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 90ccedf..41fccc0 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,10 +1,12 @@ -//! QTRNG-backed tools: dice, passwords, etc. +//! QTRNG-backed tools: dice, passwords, 8 ball, etc. mod dice; +mod eightball; mod password; pub use dice::{roll_dice, bytes_needed as dice_bytes_needed, DEFAULT_SIDES, DEFAULT_COUNT, MAX_COUNT, MAX_SIDES, MIN_SIDES}; pub use password::{ generate_password, charset_from_flags, charset_alphanumeric, charset_full, charset_hex, - DEFAULT_LENGTH, MAX_LENGTH, + DEFAULT_LENGTH, MAX_LENGTH, filter_ambiguous, }; +pub use eightball::{shake as eightball_shake, bytes_needed as eightball_bytes_needed, sentiment as eightball_sentiment, RESPONSES as EIGHTBALL_RESPONSES, NUM_RESPONSES as EIGHTBALL_NUM_RESPONSES}; diff --git a/src/tools/password.rs b/src/tools/password.rs index bf5c474..d65715e 100644 --- a/src/tools/password.rs +++ b/src/tools/password.rs @@ -94,3 +94,15 @@ pub fn bytes_needed(length: usize, charset_len: usize) -> usize { let accept_prob = threshold as f64 / u32::MAX as f64; (4.0_f64 / accept_prob * length as f64).ceil() as usize } + +/// Characters that look ambiguous in many fonts. +pub const AMBIGUOUS_CHARS: &[u8] = b"0OoIl1|"; + +/// Remove ambiguous characters from a charset. +pub fn filter_ambiguous(charset: &[u8]) -> Vec { + charset + .iter() + .copied() + .filter(|c| !AMBIGUOUS_CHARS.contains(c)) + .collect() +}