use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Instant; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use serde::{Deserialize, Serialize}; use crate::clue::{ClueDatabase, CrypticClueDb}; use crate::config::{GenerationConfig, SymmetryType}; use crate::dict::Dictionary; use crate::error::{CrucivError, Result}; use crate::grid::pattern::generate_pattern; use crate::grid::slot::{extract_slots, Direction, Slot}; use crate::grid::Grid; use crate::solver::{self, SolveResult}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlacedWord { pub word: String, pub row: usize, pub col: usize, pub direction: Direction, pub clue_number: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Clue { pub number: u32, pub direction: Direction, pub text: String, pub answer: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub definition: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub wordplay_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Puzzle { pub grid: Grid, pub words: Vec, pub clues_across: Vec, pub clues_down: Vec, pub width: usize, pub height: usize, pub symmetry: SymmetryType, pub difficulty_score: u32, pub generation_time_ms: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub theme: Option, #[serde(default)] pub theme_entries: Vec, #[serde(default)] pub is_cryptic: bool, } impl Puzzle { pub fn to_json(&self) -> serde_json::Result { serde_json::to_string_pretty(self) } pub fn from_json(json: &str) -> serde_json::Result { serde_json::from_str(json) } pub fn apply_cryptic_clues(&mut self, cryptic_db: &CrypticClueDb) -> usize { let mut count = 0; for clue in self .clues_across .iter_mut() .chain(self.clues_down.iter_mut()) { if let Some((text, definition, wordplay)) = cryptic_db.get_clue_with_meta(&clue.answer) { clue.text = text; clue.definition = definition; clue.wordplay_type = wordplay; count += 1; } } if count > 0 { self.is_cryptic = true; } count } pub fn is_fully_filled(&self) -> bool { use crate::grid::Cell; for r in 0..self.height { for c in 0..self.width { if matches!(self.grid.get(r, c), Cell::Empty) { return false; } } } // also check every white cell is covered by a word in both directions let mut across_covered = vec![vec![false; self.width]; self.height]; let mut down_covered = vec![vec![false; self.width]; self.height]; for w in &self.words { let len = w.word.len(); for i in 0..len { let (r, c) = match w.direction { Direction::Across => (w.row, w.col + i), Direction::Down => (w.row + i, w.col), }; if r < self.height && c < self.width { match w.direction { Direction::Across => across_covered[r][c] = true, Direction::Down => down_covered[r][c] = true, } } } } for r in 0..self.height { for c in 0..self.width { if matches!(self.grid.get(r, c), Cell::Letter(_)) { if !across_covered[r][c] || !down_covered[r][c] { return false; } } } } true } } pub fn assemble_puzzle( solve_result: &SolveResult, slots: &[Slot], dict: &Dictionary, clue_db: &ClueDatabase, config: &GenerationConfig, difficulty: u8, generation_time_ms: u64, theme: Option<&str>, theme_words: Option<&HashSet>, ) -> Puzzle { let mut clue_number = 1u32; let mut assigned_numbers: HashMap<(usize, usize), u32> = HashMap::new(); let mut words = Vec::new(); let mut clues_across = Vec::new(); let mut clues_down = Vec::new(); let mut sorted_slots: Vec<_> = slots.iter().collect(); sorted_slots.sort_by_key(|s| (s.row, s.col, matches!(s.direction, Direction::Down) as u8)); for slot in &sorted_slots { let num = *assigned_numbers .entry((slot.row, slot.col)) .or_insert_with(|| { let n = clue_number; clue_number += 1; n }); let word_idx = solve_result .word_assignments .iter() .find(|(si, _)| *si == slot.index) .map(|(_, wi)| *wi); if let Some(wi) = word_idx { let word_text = &dict.get(wi).text; let clue_text = match clue_db.get_clue(word_text, difficulty) { Ok(text) => text, Err(_) => clue_db.get_clue(word_text, 3).unwrap_or_else(|_| { tracing::warn!("no clue found for word: {}", word_text); format!("___ ({} letter word)", word_text.len()) }), }; words.push(PlacedWord { word: word_text.clone(), row: slot.row, col: slot.col, direction: slot.direction, clue_number: num, }); let clue = Clue { number: num, direction: slot.direction, text: clue_text, answer: word_text.clone(), definition: None, wordplay_type: None, }; match slot.direction { Direction::Across => clues_across.push(clue), Direction::Down => clues_down.push(clue), } } } let theme_entries = if let Some(tw) = theme_words { words .iter() .filter(|w| tw.contains(&w.word)) .map(|w| w.word.clone()) .collect() } else { vec![] }; Puzzle { grid: solve_result.grid.clone(), words, clues_across, clues_down, width: config.width, height: config.height, symmetry: config.symmetry, difficulty_score: 50, generation_time_ms, theme: theme.map(|s| s.to_string()), theme_entries, is_cryptic: false, } } pub fn generate_full_puzzle( config: &GenerationConfig, dict: &Dictionary, clue_db: &ClueDatabase, difficulty: u8, ) -> Result { generate_themed_puzzle(config, dict, clue_db, difficulty, None, None) } pub fn generate_themed_puzzle( config: &GenerationConfig, dict: &Dictionary, clue_db: &ClueDatabase, difficulty: u8, theme: Option<&str>, theme_words: Option<&HashSet>, ) -> Result { let start = Instant::now(); let cores = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(1) .min(8); // parallel for larger grids with multiple cores if cores > 1 && config.width > 9 { return parallel_generate( config, dict, clue_db, difficulty, theme, theme_words, cores, start, ); } sequential_generate(config, dict, clue_db, difficulty, theme, theme_words, start) } struct SolvedGrid { result: SolveResult, slots: Vec, config: GenerationConfig, } fn parallel_generate( config: &GenerationConfig, dict: &Dictionary, clue_db: &ClueDatabase, difficulty: u8, theme: Option<&str>, theme_words: Option<&HashSet>, cores: usize, start: Instant, ) -> Result { let cancelled = Arc::new(AtomicBool::new(false)); let (tx, rx) = std::sync::mpsc::channel::>(); let dict = Arc::new(dict.clone()); let max_attempts = if config.width >= 25 { 40 } else if config.width >= 19 { 30 } else { 20 }; let per_worker = (max_attempts + cores - 1) / cores; let handles: Vec<_> = (0..cores) .map(|i| { let tx = tx.clone(); let cancelled = cancelled.clone(); let dict = dict.clone(); let base_seed = config.seed; let mut os_rng = base_seed.is_none().then(StdRng::from_os_rng); let mut worker_config = config.clone(); worker_config.cancelled = Some(cancelled.clone()); let min_word_length = config.min_word_length; std::thread::spawn(move || { for attempt in 0..per_worker { if cancelled.load(Ordering::Relaxed) { return; } let mut attempt_config = worker_config.clone(); // seeded callers (daily puzzles) keep the deterministic series; // unseeded callers get fresh OS entropy per attempt instead of // silently sharing the fixed 0-based series attempt_config.seed = match base_seed { Some(s) => { let wseed = s.wrapping_add(i as u64 * 7919); Some(if attempt > 0 { wseed .wrapping_mul(6364136223846793005) .wrapping_add((attempt as u64).wrapping_mul(1442695040888963407)) } else { wseed }) } None => Some(os_rng.as_mut().unwrap().random::()), }; let grid = match generate_pattern(&attempt_config) { Ok(g) => g, Err(_) => continue, }; let slots = extract_slots(&grid, min_word_length); match solver::solve(&grid, &slots, &dict, &attempt_config) { Ok(result) => { let _ = tx.send(Ok(SolvedGrid { result, slots, config: attempt_config, })); return; } Err(_) => continue, } } }) }) .collect(); drop(tx); let mut last_err = None; for msg in rx { match msg { Ok(solved) => { let elapsed = start.elapsed().as_millis() as u64; let puzzle = assemble_puzzle( &solved.result, &solved.slots, &dict, clue_db, &solved.config, difficulty, elapsed, theme, theme_words, ); if !puzzle.is_fully_filled() { tracing::warn!("rejecting puzzle: not fully filled or uncovered cells"); continue; } cancelled.store(true, Ordering::Relaxed); return Ok(puzzle); } Err(e) => { last_err = Some(e); } } } // wait for threads to finish for h in handles { let _ = h.join(); } Err(last_err.unwrap_or_else(|| CrucivError::Generation(generation_hint(config)))) } fn sequential_generate( config: &GenerationConfig, dict: &Dictionary, clue_db: &ClueDatabase, difficulty: u8, theme: Option<&str>, theme_words: Option<&HashSet>, start: Instant, ) -> Result { let max_attempts = if config.width >= 25 { 40 } else if config.width >= 19 { 30 } else { 20 }; for attempt in 0..max_attempts { let mut attempt_config = config.clone(); if attempt > 0 { attempt_config.seed = Some( config .seed .unwrap_or(0) .wrapping_mul(6364136223846793005) .wrapping_add((attempt as u64).wrapping_mul(1442695040888963407)), ); } let grid = match generate_pattern(&attempt_config) { Ok(g) => g, Err(_) => continue, }; let slots = extract_slots(&grid, config.min_word_length); match solver::solve(&grid, &slots, &dict, &attempt_config) { Ok(result) => { let elapsed = start.elapsed().as_millis() as u64; let puzzle = assemble_puzzle( &result, &slots, dict, clue_db, config, difficulty, elapsed, theme, theme_words, ); if !puzzle.is_fully_filled() { tracing::warn!("rejecting puzzle: not fully filled or uncovered cells"); continue; } return Ok(puzzle); } Err(_) => continue, } } Err(CrucivError::Generation(generation_hint(config))) } fn generation_hint(config: &GenerationConfig) -> String { let size = config.width; let density = config.black_cell_density; let fill_pct = ((1.0 - density) * 100.0).round() as u32; if density < 0.12 { format!("Could not fill a {}x{} grid at {}% fill - try lowering the fill percentage or reducing the grid size", size, size, fill_pct) } else if size >= 19 && density < 0.18 { format!("A {}x{} grid at {}% fill is very hard to fill - try lowering the fill to around 75% or below", size, size, fill_pct) } else if size >= 15 && density < 0.15 { format!("Could not fill a {}x{} grid at {}% fill - large grids need more black cells. Try 78% fill or lower", size, size, fill_pct) } else { format!("Could not generate a {}x{} puzzle at {}% fill - try a different fill percentage or grid size", size, size, fill_pct) } }