diff --git a/cruciverb-core/src/grid/pattern.rs b/cruciverb-core/src/grid/pattern.rs new file mode 100644 index 0000000..b8901eb --- /dev/null +++ b/cruciverb-core/src/grid/pattern.rs @@ -0,0 +1,605 @@ +use rand::Rng; +use rand::SeedableRng; +use rand::rngs::StdRng; + +use crate::config::GenerationConfig; +use crate::error::{CrucivError, Result}; + +use super::{Cell, Grid}; +use super::slot::extract_slots; +use super::symmetry::mirror_coords; + +const MAX_WORD_LEN_HUGE: usize = 7; // grids 25x25+ +const MAX_WORD_LEN_LARGE: usize = 9; // grids 19x19+ + +/// Try to load a grid from pre-made templates first (fast path for 15x15 and 21x21). +/// Falls back to random generation if no templates available. +pub fn generate_pattern(config: &GenerationConfig) -> Result { + let mut rng = match config.seed { + Some(s) => StdRng::seed_from_u64(s), + None => StdRng::from_os_rng(), + }; + + // try template-based generation for large grids + if config.width >= 15 && config.height >= 15 { + if let Some(grid) = load_template(config, &mut rng) { + return Ok(grid); + } + } + + for _ in 0..100 { + let grid = try_generate(config, &mut rng); + if let Some(grid) = grid { + return Ok(grid); + } + } + + Err(CrucivError::Generation( + "failed to generate valid grid pattern after 100 attempts".into(), + )) +} + +fn load_template(config: &GenerationConfig, rng: &mut StdRng) -> Option { + let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into()); + let filename = format!("grids_{}x{}.txt", config.width, config.height); + let path = std::path::Path::new(&data_dir).join("grid-templates").join(&filename); + + let content = std::fs::read_to_string(&path).ok()?; + let templates: Vec<&str> = content.split("\n\n").filter(|s| !s.trim().is_empty()).collect(); + if templates.is_empty() { return None; } + + // try a few random templates + for _ in 0..20 { + let idx = rng.random_range(0..templates.len()); + let template = templates[idx].trim(); + let lines: Vec<&str> = template.lines().collect(); + if lines.len() != config.height { continue; } + if lines[0].len() != config.width { continue; } + + let mut grid = Grid::new(config.width, config.height); + for (r, line) in lines.iter().enumerate() { + for (c, ch) in line.chars().enumerate() { + if ch == '#' { + grid.set(r, c, Cell::Black); + } + } + } + + if is_connected(&grid) && has_valid_words(&grid, config.min_word_length) { + let slots = extract_slots(&grid, config.min_word_length); + if !slots.is_empty() { + tracing::debug!("loaded {}x{} template (index {})", config.width, config.height, idx); + return Some(grid); + } + } + } + + None +} + + +fn try_generate(config: &GenerationConfig, rng: &mut StdRng) -> Option { + let w = config.width; + let h = config.height; + + let max_word_len = if w >= 25 || h >= 25 { MAX_WORD_LEN_HUGE } + else if w >= 19 || h >= 19 { MAX_WORD_LEN_LARGE } + else { w.max(h) }; + + let min_density = if w >= 25 || h >= 25 { 0.30 } + else if w >= 19 || h >= 19 { 0.25 } + else { 0.0 }; + let effective_density = config.black_cell_density.max(min_density); + + // for high density (sparse puzzles), carve slots out of a black grid + // for normal density, add black cells to a white grid + let mut grid = if effective_density > 0.50 { + try_generate_sparse(w, h, effective_density, config, rng)? + } else { + try_generate_dense(w, h, effective_density, config, rng)? + }; + + if has_long_runs(&grid, max_word_len) { + break_long_runs(&mut grid, max_word_len, config, rng); + } + + if !is_connected(&grid) || !has_valid_words(&grid, config.min_word_length) { + return None; + } + + let slots = extract_slots(&grid, config.min_word_length); + if slots.is_empty() { + return None; + } + + Some(grid) +} + +/// Standard approach: start white, add black cells while maintaining connectivity +fn try_generate_dense( + w: usize, h: usize, density: f64, config: &GenerationConfig, rng: &mut StdRng, +) -> Option { + let mut grid = Grid::new(w, h); + let total = w * h; + let target_black = (total as f64 * density) as usize; + + let mut positions: Vec<(usize, usize)> = Vec::new(); + for r in 0..h { + for c in 0..w { + positions.push((r, c)); + } + } + shuffle(&mut positions, rng); + + let mut placed = 0; + for &(r, c) in &positions { + if placed >= target_black { break; } + if grid.get(r, c).is_black() { continue; } + + let coords = mirror_coords(r, c, w, h, config.symmetry); + let would_place = coords.iter().filter(|&&(mr, mc)| !grid.get(mr, mc).is_black()).count(); + if placed + would_place > target_black + 2 { continue; } + + for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Black); } + + if !has_valid_words(&grid, config.min_word_length) || !is_connected(&grid) { + for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); } + continue; + } + + placed = grid.black_count(); + } + + Some(grid) +} + +/// Sparse approach: start black, carve connected word slots +fn try_generate_sparse( + w: usize, h: usize, density: f64, config: &GenerationConfig, rng: &mut StdRng, +) -> Option { + let mut grid = Grid::new_black(w, h); + let total = w * h; + let target_white = ((1.0 - density) * total as f64).max(6.0) as usize; + let min_len = config.min_word_length; + + // start with one slot near the center to seed connectivity + let cr = h / 2; + let cc = w / 2; + let first_len = min_len + rng.random_range(0..=2); + let start_c = cc.saturating_sub(first_len / 2); + for i in 0..first_len { + if start_c + i < w { + let coords = mirror_coords(cr, start_c + i, w, h, config.symmetry); + for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); } + } + } + + // carve more slots, preferring positions that intersect existing white cells + for _ in 0..300 { + if grid.white_count() >= target_white { break; } + + let horizontal = rng.random_bool(0.5); + let len = min_len + rng.random_range(0..=3); + + // try to find a position that crosses an existing white cell + let mut best = None; + for _ in 0..20 { + let r = rng.random_range(0..h); + let c = rng.random_range(0..w); + + let (end_r, end_c) = if horizontal { (r, c + len - 1) } else { (r + len - 1, c) }; + if end_r >= h || end_c >= w { continue; } + + // check if this slot touches any existing white cell + let mut touches = false; + for i in 0..len { + let (pr, pc) = if horizontal { (r, c + i) } else { (r + i, c) }; + if grid.get(pr, pc).is_white() { touches = true; break; } + // also check adjacent cells + for (dr, dc) in [(-1i32,0),(1,0),(0,-1i32),(0,1)] { + let nr = pr as i32 + dr; + let nc = pc as i32 + dc; + if nr >= 0 && nc >= 0 && (nr as usize) < h && (nc as usize) < w { + if grid.get(nr as usize, nc as usize).is_white() { touches = true; break; } + } + } + if touches { break; } + } + + // first slot always ok, subsequent must touch existing + if grid.white_count() <= first_len * 2 || touches { + best = Some((r, c)); + break; + } + } + + let Some((r, c)) = best else { continue }; + + for i in 0..len { + let (pr, pc) = if horizontal { (r, c + i) } else { (r + i, c) }; + if pr < h && pc < w { + let coords = mirror_coords(pr, pc, w, h, config.symmetry); + for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); } + } + } + } + + if !is_connected(&grid) || !has_valid_words(&grid, min_len) { + return None; + } + + let slots = extract_slots(&grid, min_len); + if slots.len() < 2 { return None; } + + Some(grid) +} + +fn has_long_runs(grid: &Grid, max_len: usize) -> bool { + for r in 0..grid.height { + let mut run = 0; + for c in 0..grid.width { + if grid.get(r, c).is_white() { + run += 1; + if run > max_len { return true; } + } else { + run = 0; + } + } + } + for c in 0..grid.width { + let mut run = 0; + for r in 0..grid.height { + if grid.get(r, c).is_white() { + run += 1; + if run > max_len { return true; } + } else { + run = 0; + } + } + } + false +} + +fn break_long_runs(grid: &mut Grid, max_len: usize, config: &GenerationConfig, _rng: &mut StdRng) { + for _ in 0..100 { + if !has_long_runs(grid, max_len) { + break; + } + + let mut broke_something = false; + + // find and break a long row run + 'rows: for r in 0..grid.height { + let mut start = None; + let mut run = 0; + for c in 0..=grid.width { + if c < grid.width && grid.get(r, c).is_white() { + if start.is_none() { start = Some(c); } + run += 1; + } else { + if run > max_len { + let s = start.unwrap(); + let mid = s + run / 2; + if try_place_black(grid, r, mid, config) { + broke_something = true; + break 'rows; + } + } + start = None; + run = 0; + } + } + } + + // find and break a long column run + 'cols: for c in 0..grid.width { + let mut start = None; + let mut run = 0; + for r in 0..=grid.height { + if r < grid.height && grid.get(r, c).is_white() { + if start.is_none() { start = Some(r); } + run += 1; + } else { + if run > max_len { + let s = start.unwrap(); + let mid = s + run / 2; + if try_place_black(grid, mid, c, config) { + broke_something = true; + break 'cols; + } + } + start = None; + run = 0; + } + } + } + + if !broke_something { + break; + } + } +} + +fn try_place_black(grid: &mut Grid, r: usize, c: usize, config: &GenerationConfig) -> bool { + let coords = mirror_coords(r, c, grid.width, grid.height, config.symmetry); + for &(mr, mc) in &coords { + grid.set(mr, mc, Cell::Black); + } + if is_connected(grid) && has_valid_words(grid, config.min_word_length) { + true + } else { + for &(mr, mc) in &coords { + grid.set(mr, mc, Cell::Empty); + } + false + } +} + +fn has_valid_words(grid: &Grid, min_len: usize) -> bool { + // every white cell must belong to at least one run of min_len+ in some direction + // first compute across run length for each cell + let w = grid.width; + let h = grid.height; + let mut across_len = vec![vec![0usize; w]; h]; + for r in 0..h { + let mut start = 0; + while start < w { + if !grid.get(r, start).is_white() { start += 1; continue; } + let mut end = start; + while end < w && grid.get(r, end).is_white() { end += 1; } + let len = end - start; + for c in start..end { across_len[r][c] = len; } + start = end; + } + } + // compute down run length for each cell + let mut down_len = vec![vec![0usize; w]; h]; + for c in 0..w { + let mut start = 0; + while start < h { + if !grid.get(start, c).is_white() { start += 1; continue; } + let mut end = start; + while end < h && grid.get(end, c).is_white() { end += 1; } + let len = end - start; + for r in start..end { down_len[r][c] = len; } + start = end; + } + } + // check: every white cell must be in valid-length runs in BOTH directions + // this ensures every cell is "checked" (solvable through crossings) + for r in 0..h { + for c in 0..w { + if !grid.get(r, c).is_white() { continue; } + if across_len[r][c] < min_len || down_len[r][c] < min_len { + return false; + } + } + } + // also reject any run of 2..min_len (too short to be a word but not a single crossing cell) + for r in 0..h { + let mut run = 0; + for c in 0..w { + if grid.get(r, c).is_white() { + run += 1; + } else { + if run > 0 && run < min_len { + return false; + } + run = 0; + } + } + if run > 0 && run < min_len { + return false; + } + } + + for c in 0..w { + let mut run = 0; + for r in 0..h { + if grid.get(r, c).is_white() { + run += 1; + } else { + if run > 0 && run < min_len { + return false; + } + run = 0; + } + } + if run > 0 && run < min_len { + return false; + } + } + + true +} + +fn is_connected(grid: &Grid) -> bool { + // BFS from the first white cell - all white cells must be reachable + let mut start = None; + let mut white_count = 0; + + for r in 0..grid.height { + for c in 0..grid.width { + if grid.get(r, c).is_white() { + white_count += 1; + if start.is_none() { + start = Some((r, c)); + } + } + } + } + + let Some((sr, sc)) = start else { + return true; // no white cells, trivially connected + }; + + if white_count == 0 { + return true; + } + + let mut visited = vec![vec![false; grid.width]; grid.height]; + let mut queue = std::collections::VecDeque::new(); + queue.push_back((sr, sc)); + visited[sr][sc] = true; + let mut reached = 1; + + while let Some((r, c)) = queue.pop_front() { + for (dr, dc) in [(-1i32, 0), (1, 0), (0, -1i32), (0, 1)] { + let nr = r as i32 + dr; + let nc = c as i32 + dc; + if nr < 0 || nc < 0 { + continue; + } + let nr = nr as usize; + let nc = nc as usize; + if nr >= grid.height || nc >= grid.width { + continue; + } + if !visited[nr][nc] && grid.get(nr, nc).is_white() { + visited[nr][nc] = true; + reached += 1; + queue.push_back((nr, nc)); + } + } + } + + reached == white_count +} + +fn shuffle(v: &mut [T], rng: &mut StdRng) { + for i in (1..v.len()).rev() { + let j = rng.random_range(0..=i); + v.swap(i, j); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{GenerationConfig, SymmetryType}; + + #[test] + fn generates_5x5() { + let config = GenerationConfig { + width: 5, + height: 5, + seed: Some(42), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + assert_eq!(grid.width, 5); + assert_eq!(grid.height, 5); + assert!(is_connected(&grid)); + } + + #[test] + fn generates_9x9() { + let config = GenerationConfig { + width: 9, + height: 9, + seed: Some(123), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + assert!(is_connected(&grid)); + assert!(has_valid_words(&grid, 3)); + } + + #[test] + fn generates_15x15() { + let config = GenerationConfig { + width: 15, + height: 15, + seed: Some(999), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + assert!(is_connected(&grid)); + let slots = extract_slots(&grid, 3); + assert!(!slots.is_empty()); + } + + #[test] + fn respects_symmetry_180() { + let config = GenerationConfig { + width: 7, + height: 7, + symmetry: SymmetryType::Rotational180, + seed: Some(77), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + for r in 0..7 { + for c in 0..7 { + assert_eq!( + grid.get(r, c).is_black(), + grid.get(6 - r, 6 - c).is_black(), + "symmetry violated at ({}, {})", + r, + c + ); + } + } + } + + #[test] + fn respects_mirror_symmetry() { + let config = GenerationConfig { + width: 7, + height: 7, + symmetry: SymmetryType::Mirror, + seed: Some(55), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + for r in 0..7 { + for c in 0..7 { + assert_eq!( + grid.get(r, c).is_black(), + grid.get(r, 6 - c).is_black(), + "mirror symmetry violated at ({}, {})", + r, + c + ); + } + } + } + + #[test] + fn no_short_words() { + let config = GenerationConfig { + width: 9, + height: 9, + min_word_length: 3, + seed: Some(200), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + assert!(has_valid_words(&grid, 3)); + } + + #[test] + fn connectivity() { + let mut g = Grid::new(5, 5); + // split grid in half with a black column + for r in 0..5 { + g.set(r, 2, Cell::Black); + } + assert!(!is_connected(&g)); + } + + #[test] + fn density_in_range() { + let config = GenerationConfig { + width: 11, + height: 11, + black_cell_density: 0.25, + seed: Some(300), + ..Default::default() + }; + let grid = generate_pattern(&config).unwrap(); + let d = grid.density(); + // allow some tolerance since symmetry constraints mean we can't hit exact density + assert!(d > 0.10 && d < 0.40, "density {} out of range", d); + } +}