From e9224b1255adc0cffb5fe04efef48a6bd34ceeaf Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 21 Jul 2026 21:24:38 +0100 Subject: [PATCH 1/2] puzzle: fix sequence next-term bug; add third-party example and contributing guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 21 ++++ contracts/puzzle/src/sequence.rs | 171 +++++++++++++++++++++++++++++++ examples/third_party_puzzle.rs | 50 +++++++++ 3 files changed, 242 insertions(+) create mode 100644 contracts/puzzle/src/sequence.rs create mode 100644 examples/third_party_puzzle.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6aa387..46ffa8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,27 @@ chore/maintenance-task 5. Run static analysis: `npm run slither` (if available) 6. Open a Pull Request +### Adding a new puzzle type (Rust/puzzle crate) + +The puzzle system uses a trait-based registry so new puzzle categories can be added without changing core dispatch logic. + +Short guide: + +1. Add a new Rust type that implements the `puzzle::traits::Puzzle` trait. Implement these methods: + - `name(&self) -> &str` — the unique type name (e.g. "my_puzzle"). + - `generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance` — produce a deterministic PuzzleInstance. + - `check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool` — validate answers. + - `hint(&self, instance: &PuzzleInstance, hint_level: u32) -> String` — return hints. + - `difficulty(&self) -> Difficulty` — default difficulty for the type. + +2. Register your implementation with `puzzle::PuzzleRegistry` at startup (or from an examples crate) by calling `registry.register(Box::new(YourPuzzle::new()));`. + +3. Add unit tests under `contracts/puzzle/src` or in your own crate to exercise generation, checking and hints. + +4. (Optional) Provide an example under `examples/` demonstrating third-party registration and usage. + +See `contracts/puzzle` for built-in implementations and `examples/third_party_puzzle.rs` for a minimal example. + --- ## Smart Contract Standards diff --git a/contracts/puzzle/src/sequence.rs b/contracts/puzzle/src/sequence.rs new file mode 100644 index 0000000..082c277 --- /dev/null +++ b/contracts/puzzle/src/sequence.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleInstance; +use crate::traits::Puzzle; + +/// Built-in number-sequence puzzle. +/// +/// Presents a numeric sequence and asks the solver to find the next number. +/// Sequence patterns (arithmetic, geometric, Fibonacci-like) depend on difficulty. +pub struct SequencePuzzle; + +impl SequencePuzzle { + pub fn new() -> Self { + SequencePuzzle + } +} + +impl Default for SequencePuzzle { + fn default() -> Self { + Self::new() + } +} + +impl Puzzle for SequencePuzzle { + fn name(&self) -> &str { + "sequence" + } + + fn generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance { + let (terms, answer, pattern_desc) = match difficulty { + Difficulty::Easy => { + // Arithmetic: start + increment each step + let start = (seed % 10) + 1; + let step = ((seed / 7) % 5) + 2; + let terms: Vec = (0..5).map(|i| start + i * step).collect(); + let next = start + 5 * step; + (terms, next, format!("add {} each time", step)) + } + Difficulty::Medium => { + // Geometric: multiply by a constant + let start = (seed % 5) + 2; + let ratio = ((seed / 7) % 3) + 2; + let mut terms = Vec::new(); + let mut val = start; + for _ in 0..5 { + terms.push(val); + val *= ratio; + } + let next = val; + (terms, next, format!("multiply by {} each time", ratio)) + } + Difficulty::Hard => { + // Fibonacci-like: each term is sum of two preceding + let a = (seed % 5) + 1; + let b = ((seed / 7) % 5) + 1; + let mut terms = vec![a, b]; + for _ in 2..6 { + let next = terms[terms.len() - 1] + terms[terms.len() - 2]; + terms.push(next); + } + // The terms vector contains the first 6 values; display the first 5 and + // use the 6th value as the "next" term. + let next_val = terms[terms.len() - 1]; + let display_terms = terms[..5].to_vec(); + (display_terms, next_val, "each number is the sum of the two before it".to_string()) + } + Difficulty::Expert => { + // Squares: n^2 + offset + let offset = (seed % 10) as i64; + let start_n = ((seed / 7) % 5) + 1; + let terms: Vec = (0..5) + .map(|i| ((start_n + i) as i64).pow(2) + offset) + .collect(); + let next = ((start_n + 5) as i64).pow(2) + offset; + (terms.into_iter().map(|v| v as u64).collect(), next as u64, "each term is n² + constant".to_string()) + } + }; + + let terms_str: Vec = terms.iter().map(|t| t.to_string()).collect(); + let question = format!( + "What comes next? {}", + terms_str.join(", ") + ); + + let id = format!("seq_{}_{}", difficulty, seed); + let hints = vec![ + format!("The answer is a {}-digit number", answer.to_string().len()), + format!("Pattern: {}", pattern_desc), + format!("The first digit of the answer is {}", answer.to_string().chars().next().unwrap_or('0')), + format!("The answer is {}", answer), + ]; + + let mut metadata = HashMap::new(); + metadata.insert("pattern".to_string(), pattern_desc); + metadata.insert("terms".to_string(), terms_str.join(",")); + + let mut instance = + PuzzleInstance::new(id, question, hints, answer.to_string(), difficulty); + instance.metadata = metadata; + instance + } + + fn check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool { + instance.check(answer) + } + + fn hint(&self, instance: &PuzzleInstance, hint_level: u32) -> String { + let idx = (hint_level as usize).min(instance.hints.len().saturating_sub(1)); + instance.hints[idx].clone() + } + + fn difficulty(&self) -> Difficulty { + Difficulty::Medium + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sequence_easy() { + let puzzle = SequencePuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, 42); + // seed=42: start=(42%10)+1=3, step=((42/7)%5)+2=(6%5)+2=3 + // terms: 3,6,9,12,15 next=18 + assert_eq!(instance.answer(), "18"); + assert!(puzzle.check_answer(&instance, "18")); + assert!(!puzzle.check_answer(&instance, "19")); + } + + #[test] + fn test_sequence_medium() { + let puzzle = SequencePuzzle::new(); + let instance = puzzle.generate(Difficulty::Medium, 14); + // seed=14: start=(14%5)+2=(4)+2=6, ratio=((14/7)%3)+2=(2%3)+2=4 + // terms: 6,24,96,384,1536 next=6144 + assert_eq!(instance.answer(), "6144"); + assert!(puzzle.check_answer(&instance, "6144")); + } + + #[test] + fn test_sequence_hard() { + let puzzle = SequencePuzzle::new(); + let instance = puzzle.generate(Difficulty::Hard, 20); + // seed=20: a=(20%5)+1=1, b=((20/7)%5)+1=(2%5)+1=3 + // fib-like: 1,3,4,7,11 next=18 + assert_eq!(instance.answer(), "18"); + assert!(puzzle.check_answer(&instance, "18")); + } + + #[test] + fn test_sequence_deterministic() { + let puzzle = SequencePuzzle::new(); + let a = puzzle.generate(Difficulty::Easy, 99); + let b = puzzle.generate(Difficulty::Easy, 99); + assert_eq!(a.question, b.question); + assert_eq!(a.answer(), b.answer()); + } + + #[test] + fn test_sequence_hints() { + let puzzle = SequencePuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, 42); + let h0 = puzzle.hint(&instance, 0); + assert!(h0.contains("digit")); + let h_last = puzzle.hint(&instance, 100); + assert!(h_last.contains("18")); + } +} diff --git a/examples/third_party_puzzle.rs b/examples/third_party_puzzle.rs new file mode 100644 index 0000000..eddfa71 --- /dev/null +++ b/examples/third_party_puzzle.rs @@ -0,0 +1,50 @@ +use puzzle::{Puzzle, PuzzleInstance, PuzzleRegistry, Difficulty}; + +// Example of a third-party puzzle implementation and registration. +// Build with: cargo run -p mesh-puzzle-examples --example third_party_puzzle + +struct EchoPuzzle; + +impl EchoPuzzle { + fn new() -> Self { EchoPuzzle } +} + +impl Puzzle for EchoPuzzle { + fn name(&self) -> &str { "echo" } + + fn generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance { + let id = format!("echo_{}_{}", difficulty, seed); + let question = format!("Echo puzzle (seed={}): say the secret word", seed); + let hints = vec!["It's a friendly greeting".into(), "The answer is 'hello'".into()]; + let answer = "hello".to_string(); + PuzzleInstance::new(id, question, hints, answer, difficulty) + } + + fn check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool { + instance.check(answer) + } + + fn hint(&self, instance: &PuzzleInstance, hint_level: u32) -> String { + let idx = (hint_level as usize).min(instance.hints.len().saturating_sub(1)); + instance.hints[idx].clone() + } + + fn difficulty(&self) -> Difficulty { + Difficulty::Easy + } +} + +fn main() { + let mut reg = PuzzleRegistry::new(); + // Register builtins (optional) and a third-party puzzle + puzzle::register_builtins(&mut reg); + reg.register(Box::new(EchoPuzzle::new())); + + // Generate a puzzle via the registry + let inst = reg.generate("echo", Difficulty::Easy, 7).expect("should generate"); + println!("Generated: {}\nQuestion: {}", inst.id, inst.question); + + // Hint and check + println!("Hint level 0: {}", reg.hint("echo", &inst, 0).unwrap()); + println!("Answer check 'hello': {}", reg.check_answer("echo", &inst, "hello").unwrap()); +} From dbd953e881a843b8d84a38d9d4d3363a746e08de Mon Sep 17 00:00:00 2001 From: OluRemiFour Date: Tue, 21 Jul 2026 21:26:53 +0100 Subject: [PATCH 2/2] Introduce a plugin trait for custom puzzle types --- Cargo.lock | 11 ++ Cargo.toml | 2 + contracts/puzzle/Cargo.toml | 10 ++ contracts/puzzle/src/difficulty.rs | 31 +++++ contracts/puzzle/src/lib.rs | 142 +++++++++++++++++++++++ contracts/puzzle/src/math.rs | 156 ++++++++++++++++++++++++++ contracts/puzzle/src/puzzle.rs | 48 ++++++++ contracts/puzzle/src/registry.rs | 90 +++++++++++++++ contracts/puzzle/src/traits.rs | 26 +++++ contracts/puzzle/src/word_scramble.rs | 154 +++++++++++++++++++++++++ examples/Cargo.toml | 12 ++ 11 files changed, 682 insertions(+) create mode 100644 contracts/puzzle/Cargo.toml create mode 100644 contracts/puzzle/src/difficulty.rs create mode 100644 contracts/puzzle/src/lib.rs create mode 100644 contracts/puzzle/src/math.rs create mode 100644 contracts/puzzle/src/puzzle.rs create mode 100644 contracts/puzzle/src/registry.rs create mode 100644 contracts/puzzle/src/traits.rs create mode 100644 contracts/puzzle/src/word_scramble.rs create mode 100644 examples/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index 8573992..c020f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,13 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "mesh-puzzle-examples" +version = "0.1.0" +dependencies = [ + "puzzle", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -952,6 +959,10 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "puzzle" +version = "0.1.0" + [[package]] name = "quote" version = "1.0.46" diff --git a/Cargo.toml b/Cargo.toml index 2c705a1..b123cb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "contracts/registry", "contracts/escrow", "contracts/settlement", + "contracts/puzzle", + "examples", ] [workspace.dependencies] diff --git a/contracts/puzzle/Cargo.toml b/contracts/puzzle/Cargo.toml new file mode 100644 index 0000000..eea1588 --- /dev/null +++ b/contracts/puzzle/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "puzzle" +version = "0.1.0" +edition = "2021" +description = "Core puzzle trait, built-in implementations, and registry for ThinkMesh" + +[lib] +crate-type = ["lib"] + +[dependencies] diff --git a/contracts/puzzle/src/difficulty.rs b/contracts/puzzle/src/difficulty.rs new file mode 100644 index 0000000..a995c56 --- /dev/null +++ b/contracts/puzzle/src/difficulty.rs @@ -0,0 +1,31 @@ +/// Difficulty levels for puzzles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Difficulty { + Easy, + Medium, + Hard, + Expert, +} + +impl Difficulty { + /// Numeric weight used for scoring (higher = more points). + pub fn weight(&self) -> u32 { + match self { + Difficulty::Easy => 1, + Difficulty::Medium => 2, + Difficulty::Hard => 3, + Difficulty::Expert => 5, + } + } +} + +impl std::fmt::Display for Difficulty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Difficulty::Easy => write!(f, "Easy"), + Difficulty::Medium => write!(f, "Medium"), + Difficulty::Hard => write!(f, "Hard"), + Difficulty::Expert => write!(f, "Expert"), + } + } +} diff --git a/contracts/puzzle/src/lib.rs b/contracts/puzzle/src/lib.rs new file mode 100644 index 0000000..7d7fc91 --- /dev/null +++ b/contracts/puzzle/src/lib.rs @@ -0,0 +1,142 @@ +//! # Puzzle Crate +//! +//! Core puzzle trait, built-in implementations, and a dynamic registry +//! for the ThinkMesh protocol. +//! +//! ## Adding a new puzzle type +//! +//! 1. Implement the [`Puzzle`](traits::Puzzle) trait for your type. +//! 2. Register it with [`PuzzleRegistry`](registry::PuzzleRegistry) at startup. +//! +//! No changes to core dispatch logic are needed — the registry handles +//! routing by puzzle name. + +pub mod difficulty; +pub mod math; +pub mod puzzle; +pub mod registry; +pub mod sequence; +pub mod traits; +pub mod word_scramble; + +// Re-exports for convenience +pub use difficulty::Difficulty; +pub use puzzle::PuzzleInstance; +pub use registry::PuzzleRegistry; +pub use traits::Puzzle; + +/// Register all built-in puzzle types into the given registry. +pub fn register_builtins(registry: &mut PuzzleRegistry) { + registry.register(Box::new(math::MathPuzzle::new())); + registry.register(Box::new(word_scramble::WordScramblePuzzle::new())); + registry.register(Box::new(sequence::SequencePuzzle::new())); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup_registry() -> PuzzleRegistry { + let mut reg = PuzzleRegistry::new(); + register_builtins(&mut reg); + reg + } + + #[test] + fn test_registry_has_builtins() { + let reg = setup_registry(); + assert!(reg.get("math").is_some()); + assert!(reg.get("word_scramble").is_some()); + assert!(reg.get("sequence").is_some()); + assert_eq!(reg.len(), 3); + } + + #[test] + fn test_registry_list() { + let reg = setup_registry(); + let mut names = reg.list(); + names.sort(); + assert_eq!(names, vec!["math", "sequence", "word_scramble"]); + } + + #[test] + fn test_registry_generate_and_check() { + let reg = setup_registry(); + + // Math + let inst = reg.generate("math", Difficulty::Easy, 42).unwrap(); + assert!(reg.check_answer("math", &inst, inst.answer()).unwrap()); + + // Word scramble + let inst = reg.generate("word_scramble", Difficulty::Easy, 0).unwrap(); + assert!(reg.check_answer("word_scramble", &inst, inst.answer()).unwrap()); + + // Sequence + let inst = reg.generate("sequence", Difficulty::Easy, 42).unwrap(); + assert!(reg.check_answer("sequence", &inst, inst.answer()).unwrap()); + } + + #[test] + fn test_registry_hint() { + let reg = setup_registry(); + let inst = reg.generate("math", Difficulty::Easy, 42).unwrap(); + let hint = reg.hint("math", &inst, 0).unwrap(); + assert!(!hint.is_empty()); + } + + #[test] + fn test_registry_difficulty() { + let reg = setup_registry(); + assert_eq!(reg.difficulty("math"), Some(Difficulty::Easy)); + assert_eq!(reg.difficulty("word_scramble"), Some(Difficulty::Medium)); + assert_eq!(reg.difficulty("sequence"), Some(Difficulty::Medium)); + assert_eq!(reg.difficulty("nonexistent"), None); + } + + #[test] + fn test_registry_unknown_puzzle() { + let reg = setup_registry(); + assert!(reg.get("nonexistent").is_none()); + assert!(reg.generate("nonexistent", Difficulty::Easy, 0).is_none()); + assert!(reg.check_answer("nonexistent", &PuzzleInstance::new( + "".into(), "".into(), vec![], "".into(), Difficulty::Easy + ), "x").is_none()); + } + + #[test] + fn test_custom_puzzle_registration() { + use crate::traits::Puzzle; + use crate::difficulty::Difficulty; + use crate::puzzle::PuzzleInstance; + + struct EchoPuzzle; + + impl Puzzle for EchoPuzzle { + fn name(&self) -> &str { "echo" } + fn generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance { + PuzzleInstance::new( + format!("echo_{}", seed), + "Say hello".into(), + vec!["Just say it".into()], + "hello".into(), + difficulty, + ) + } + fn check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool { + instance.check(answer) + } + fn hint(&self, instance: &PuzzleInstance, _level: u32) -> String { + instance.hints[0].clone() + } + fn difficulty(&self) -> Difficulty { Difficulty::Easy } + } + + let mut reg = PuzzleRegistry::new(); + reg.register(Box::new(EchoPuzzle)); + assert!(reg.get("echo").is_some()); + + let inst = reg.generate("echo", Difficulty::Easy, 1).unwrap(); + assert!(reg.check_answer("echo", &inst, "hello").unwrap()); + assert!(!reg.check_answer("echo", &inst, "goodbye").unwrap()); + } +} diff --git a/contracts/puzzle/src/math.rs b/contracts/puzzle/src/math.rs new file mode 100644 index 0000000..baf617f --- /dev/null +++ b/contracts/puzzle/src/math.rs @@ -0,0 +1,156 @@ +use std::collections::HashMap; + +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleInstance; +use crate::traits::Puzzle; + +/// Built-in arithmetic puzzle. +/// +/// Generates math problems (addition, subtraction, multiplication) +/// whose complexity scales with difficulty. +pub struct MathPuzzle; + +impl MathPuzzle { + pub fn new() -> Self { + MathPuzzle + } +} + +impl Default for MathPuzzle { + fn default() -> Self { + Self::new() + } +} + +impl Puzzle for MathPuzzle { + fn name(&self) -> &str { + "math" + } + + fn generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance { + let (a, b, op, op_symbol) = match difficulty { + Difficulty::Easy => { + let a = (seed % 10) + 1; + let b = ((seed / 7) % 10) + 1; + (a, b, 0u8, "+") + } + Difficulty::Medium => { + let a = (seed % 50) + 10; + let b = ((seed / 7) % 50) + 10; + (a, b, 1u8, "-") + } + Difficulty::Hard => { + let a = (seed % 12) + 2; + let b = ((seed / 7) % 12) + 2; + (a, b, 2u8, "*") + } + Difficulty::Expert => { + let a = (seed % 50) + 10; + let b = ((seed / 7) % 20) + 5; + (a, b, 0u8, "+") + } + }; + + let (answer, question) = match op { + 0 => (format!("{}", a + b), format!("What is {} + {}?", a, b)), + 1 => { + let (big, small) = if a >= b { (a, b) } else { (b, a) }; + (format!("{}", big - small), format!("What is {} - {}?", big, small)) + } + 2 => (format!("{}", a * b), format!("What is {} * {}?", a, b)), + _ => unreachable!(), + }; + + let id = format!("math_{}_{}", difficulty, seed); + let hints = vec![ + format!("The answer is between {} and {}", answer.parse::().unwrap_or(0).saturating_sub(10), answer.parse::().unwrap_or(0) + 10), + format!("The first digit is {}", answer.chars().next().unwrap_or('0')), + format!("The answer is {}", answer), + ]; + + let mut metadata = HashMap::new(); + metadata.insert("op".to_string(), op_symbol.to_string()); + metadata.insert("a".to_string(), a.to_string()); + metadata.insert("b".to_string(), b.to_string()); + + let mut instance = PuzzleInstance::new(id, question, hints, answer, difficulty); + instance.metadata = metadata; + instance + } + + fn check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool { + instance.check(answer) + } + + fn hint(&self, instance: &PuzzleInstance, hint_level: u32) -> String { + let idx = (hint_level as usize).min(instance.hints.len().saturating_sub(1)); + instance.hints[idx].clone() + } + + fn difficulty(&self) -> Difficulty { + Difficulty::Easy + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_math_easy_addition() { + let puzzle = MathPuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, 42); + // seed=42: a=(42%10)+1=3, b=((42/7)%10)+1=(6%10)+1=7 => 3+7=10 + assert_eq!(instance.answer(), "10"); + assert!(puzzle.check_answer(&instance, "10")); + assert!(!puzzle.check_answer(&instance, "11")); + assert!(instance.question.contains("+")); + } + + #[test] + fn test_math_medium_subtraction() { + let puzzle = MathPuzzle::new(); + let instance = puzzle.generate(Difficulty::Medium, 100); + // seed=100: a=(100%50)+10=10, b=((100/7)%50)+10=(14%50)+10=24 + // big=24, small=10 => 24-10=14 + assert_eq!(instance.answer(), "14"); + assert!(puzzle.check_answer(&instance, "14")); + assert!(instance.question.contains("-")); + } + + #[test] + fn test_math_hard_multiplication() { + let puzzle = MathPuzzle::new(); + let instance = puzzle.generate(Difficulty::Hard, 30); + // seed=30: a=(30%12)+2=8, b=((30/7)%12)+2=(4%12)+2=6 => 8*6=48 + assert_eq!(instance.answer(), "48"); + assert!(puzzle.check_answer(&instance, "48")); + assert!(instance.question.contains("*")); + } + + #[test] + fn test_math_hints_progressive() { + let puzzle = MathPuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, 42); + let h0 = puzzle.hint(&instance, 0); + let h1 = puzzle.hint(&instance, 1); + let h2 = puzzle.hint(&instance, 2); + // Last hint reveals the answer + assert!(h2.contains("10")); + assert!(!h0.is_empty()); + assert!(!h1.is_empty()); + } + + #[test] + fn test_math_difficulty_default() { + let puzzle = MathPuzzle::new(); + assert_eq!(puzzle.difficulty(), Difficulty::Easy); + } + + #[test] + fn test_math_case_insensitive_answer() { + let puzzle = MathPuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, 42); + assert!(puzzle.check_answer(&instance, " 10 ")); + } +} diff --git a/contracts/puzzle/src/puzzle.rs b/contracts/puzzle/src/puzzle.rs new file mode 100644 index 0000000..32f4ef0 --- /dev/null +++ b/contracts/puzzle/src/puzzle.rs @@ -0,0 +1,48 @@ +use crate::Difficulty; + +/// A generated puzzle instance ready to be presented to a solver. +#[derive(Debug, Clone)] +pub struct PuzzleInstance { + /// Unique identifier for this puzzle instance. + pub id: String, + /// The puzzle question/text presented to the solver. + pub question: String, + /// Available hints (from mild to revealing). + pub hints: Vec, + /// The correct answer (kept secret from the solver). + answer: String, + /// Difficulty level of this instance. + pub difficulty: Difficulty, + /// Arbitrary metadata (e.g. "op" => "addition" for math puzzles). + pub metadata: std::collections::HashMap, +} + +impl PuzzleInstance { + /// Create a new puzzle instance. + pub fn new( + id: String, + question: String, + hints: Vec, + answer: String, + difficulty: Difficulty, + ) -> Self { + Self { + id, + question, + hints, + answer, + difficulty, + metadata: std::collections::HashMap::new(), + } + } + + /// Verify whether `attempt` matches the correct answer (case-insensitive, trimmed). + pub fn check(&self, attempt: &str) -> bool { + self.answer.eq_ignore_ascii_case(&attempt.trim()) + } + + /// Return the stored answer (for testing / debugging). + pub fn answer(&self) -> &str { + &self.answer + } +} diff --git a/contracts/puzzle/src/registry.rs b/contracts/puzzle/src/registry.rs new file mode 100644 index 0000000..25ea9bf --- /dev/null +++ b/contracts/puzzle/src/registry.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; + +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleInstance; +use crate::traits::Puzzle; + +/// Registry that holds puzzle implementations keyed by name. +/// +/// New puzzle types can be registered at startup without touching +/// any dispatch logic — just call [`register`](PuzzleRegistry::register). +pub struct PuzzleRegistry { + puzzles: HashMap>, +} + +impl PuzzleRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + puzzles: HashMap::new(), + } + } + + /// Register a puzzle implementation. + /// The puzzle's [`name()`](Puzzle::name) is used as the key. + pub fn register(&mut self, puzzle: Box) { + let name = puzzle.name().to_string(); + self.puzzles.insert(name, puzzle); + } + + /// Look up a puzzle type by name. + pub fn get(&self, name: &str) -> Option<&dyn Puzzle> { + self.puzzles.get(name).map(|p| p.as_ref()) + } + + /// List all registered puzzle type names. + pub fn list(&self) -> Vec { + self.puzzles.keys().cloned().collect() + } + + /// Convenience: generate a puzzle instance by type name. + pub fn generate( + &self, + name: &str, + difficulty: Difficulty, + seed: u64, + ) -> Option { + self.get(name).map(|p| p.generate(difficulty, seed)) + } + + /// Convenience: check an answer by type name. + pub fn check_answer( + &self, + name: &str, + instance: &PuzzleInstance, + answer: &str, + ) -> Option { + self.get(name).map(|p| p.check_answer(instance, answer)) + } + + /// Convenience: get a hint by type name. + pub fn hint( + &self, + name: &str, + instance: &PuzzleInstance, + hint_level: u32, + ) -> Option { + self.get(name).map(|p| p.hint(instance, hint_level)) + } + + /// Convenience: get default difficulty by type name. + pub fn difficulty(&self, name: &str) -> Option { + self.get(name).map(|p| p.difficulty()) + } + + /// Number of registered puzzle types. + pub fn len(&self) -> usize { + self.puzzles.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.puzzles.is_empty() + } +} + +impl Default for PuzzleRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/contracts/puzzle/src/traits.rs b/contracts/puzzle/src/traits.rs new file mode 100644 index 0000000..ea088f1 --- /dev/null +++ b/contracts/puzzle/src/traits.rs @@ -0,0 +1,26 @@ +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleInstance; + +/// Core trait for all puzzle types. +/// +/// Implement this trait to create a new puzzle category that can be +/// registered with [`PuzzleRegistry`](crate::registry::PuzzleRegistry) +/// and dispatched without modifying core logic. +pub trait Puzzle { + /// Human-readable name of this puzzle type (e.g. "math", "word_scramble"). + fn name(&self) -> &str; + + /// Generate a new puzzle instance at the given difficulty using a seed + /// for deterministic reproducibility. + fn generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance; + + /// Check whether the supplied answer is correct for the given instance. + fn check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool; + + /// Return a hint string for the given instance. + /// `hint_level` controls how revealing the hint is (0 = mild, higher = more revealing). + fn hint(&self, instance: &PuzzleInstance, hint_level: u32) -> String; + + /// The default difficulty for this puzzle type. + fn difficulty(&self) -> Difficulty; +} diff --git a/contracts/puzzle/src/word_scramble.rs b/contracts/puzzle/src/word_scramble.rs new file mode 100644 index 0000000..f14f8a5 --- /dev/null +++ b/contracts/puzzle/src/word_scramble.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; + +use crate::difficulty::Difficulty; +use crate::puzzle::PuzzleInstance; +use crate::traits::Puzzle; + +/// Built-in word-scramble puzzle. +/// +/// Given a scrambled word, the solver must guess the original word. +/// Word lists are selected by difficulty; the scramble order is +/// derived from the seed for reproducibility. +pub struct WordScramblePuzzle; + +impl WordScramblePuzzle { + pub fn new() -> Self { + WordScramblePuzzle + } + + fn words_for_difficulty(difficulty: Difficulty) -> &'static [&'static str] { + match difficulty { + Difficulty::Easy => &["cat", "dog", "sun", "hat", "run", "cup", "bed", "pen"], + Difficulty::Medium => &[ + "planet", "garden", "bridge", "castle", "rocket", "forest", "ocean", "winter", + ], + Difficulty::Hard => &[ + "algorithm", "adventure", "chocolate", "butterfly", "telephone", "dinosaur", + "microscope", "universe", + ], + Difficulty::Expert => &[ + "cryptographic", "extraordinary", "consciousness", "revolutionary", + "infrastructure", "pharmaceutical", "quintessential", "interstellar", + ], + } + } + + /// Deterministic scramble: rotate characters by an amount derived from seed. + fn scramble(word: &str, seed: u64) -> String { + let chars: Vec = word.chars().collect(); + let len = chars.len(); + if len <= 1 { + return word.to_string(); + } + let shift = (seed % (len as u64 - 1)) + 1; + let mut scrambled = chars.clone(); + for i in 0..len { + scrambled[i] = chars[(i + shift as usize) % len]; + } + // Make sure the scrambled version differs from the original + if scrambled == chars { + scrambled.reverse(); + } + scrambled.into_iter().collect() + } +} + +impl Default for WordScramblePuzzle { + fn default() -> Self { + Self::new() + } +} + +impl Puzzle for WordScramblePuzzle { + fn name(&self) -> &str { + "word_scramble" + } + + fn generate(&self, difficulty: Difficulty, seed: u64) -> PuzzleInstance { + let words = Self::words_for_difficulty(difficulty); + let word = words[(seed as usize) % words.len()]; + let scrambled = Self::scramble(word, seed); + + let id = format!("word_{}_{}", difficulty, seed); + let question = format!("Unscramble this word: {}", scrambled); + let hints = vec![ + format!("The word has {} letters", word.len()), + format!("The first letter is '{}'", word.chars().next().unwrap()), + format!("The word is related to everyday things"), + format!("The answer is '{}'", word), + ]; + + let mut metadata = HashMap::new(); + metadata.insert("original_word".to_string(), word.to_string()); + metadata.insert("scrambled".to_string(), scrambled); + + let mut instance = + PuzzleInstance::new(id, question, hints, word.to_string(), difficulty); + instance.metadata = metadata; + instance + } + + fn check_answer(&self, instance: &PuzzleInstance, answer: &str) -> bool { + instance.check(answer) + } + + fn hint(&self, instance: &PuzzleInstance, hint_level: u32) -> String { + let idx = (hint_level as usize).min(instance.hints.len().saturating_sub(1)); + instance.hints[idx].clone() + } + + fn difficulty(&self) -> Difficulty { + Difficulty::Medium + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_word_scramble_easy() { + let puzzle = WordScramblePuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, 0); + // seed=0: word = words[0%8] = "cat", scramble with shift=0%2+1=1 + assert!(puzzle.check_answer(&instance, "cat")); + assert!(!puzzle.check_answer(&instance, "dog")); + } + + #[test] + fn test_word_scramble_deterministic() { + let puzzle = WordScramblePuzzle::new(); + let a = puzzle.generate(Difficulty::Easy, 42); + let b = puzzle.generate(Difficulty::Easy, 42); + assert_eq!(a.question, b.question); + assert_eq!(a.answer(), b.answer()); + } + + #[test] + fn test_word_scramble_hints() { + let puzzle = WordScramblePuzzle::new(); + let instance = puzzle.generate(Difficulty::Medium, 0); + let h0 = puzzle.hint(&instance, 0); + assert!(h0.contains("letters")); + let last = puzzle.hint(&instance, 10); + assert!(last.contains("answer")); + } + + #[test] + fn test_word_scramble_difficulty_default() { + let puzzle = WordScramblePuzzle::new(); + assert_eq!(puzzle.difficulty(), Difficulty::Medium); + } + + #[test] + fn test_scramble_differs_from_original() { + // Verify scramble always produces a different string + for seed in 0..100 { + let puzzle = WordScramblePuzzle::new(); + let instance = puzzle.generate(Difficulty::Easy, seed); + let scrambled = instance.metadata.get("scrambled").unwrap(); + let original = instance.answer(); + assert_ne!(scrambled, original, "Scrambled word should differ from original (seed={})", seed); + } + } +} diff --git a/examples/Cargo.toml b/examples/Cargo.toml new file mode 100644 index 0000000..9f2fead --- /dev/null +++ b/examples/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mesh-puzzle-examples" +version = "0.1.0" +edition = "2021" +publish = false + +[[example]] +name = "third_party_puzzle" +path = "third_party_puzzle.rs" + +[dependencies] +puzzle = { path = "../contracts/puzzle" }