diff --git a/cruciverb-core/src/puzzle.rs b/cruciverb-core/src/puzzle.rs new file mode 100644 index 0000000..f80daca --- /dev/null +++ b/cruciverb-core/src/puzzle.rs @@ -0,0 +1,470 @@ +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) + } +}